diff --git a/package-lock.json b/package-lock.json index 31d7463938053e..2e614b1fde4b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17498,6 +17498,7 @@ "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", "@wordpress/element": "file:packages/element", + "@wordpress/experiments": "file:packages/experiments", "@wordpress/hooks": "file:packages/hooks", "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", @@ -17593,6 +17594,7 @@ "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", "@wordpress/element": "file:packages/element", + "@wordpress/experiments": "file:packages/experiments", "@wordpress/hooks": "file:packages/hooks", "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", @@ -17765,6 +17767,7 @@ "@wordpress/compose": "file:packages/compose", "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", + "@wordpress/experiments": "file:packages/experiments", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/priority-queue": "file:packages/priority-queue", "@wordpress/redux-routine": "file:packages/redux-routine", @@ -19527,7 +19530,7 @@ "app-root-dir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz", - "integrity": "sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==", + "integrity": "sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=", "dev": true }, "app-root-path": { @@ -27809,7 +27812,7 @@ "babel-plugin-add-react-displayname": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/babel-plugin-add-react-displayname/-/babel-plugin-add-react-displayname-0.0.5.tgz", - "integrity": "sha512-LY3+Y0XVDYcShHHorshrDbt4KFWL4bSeniCtl4SYZbask+Syngk1uMPCeN9+nSiZo6zX5s0RTq/J9Pnaaf/KHw==", + "integrity": "sha1-M51M3be2X9YtHfnbn+BN4TQSK9U=", "dev": true }, "babel-plugin-apply-mdx-type-prop": { @@ -28232,7 +28235,7 @@ "batch-processor": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz", - "integrity": "sha512-xoLQD8gmmR32MeuBHgH0Tzd5PuSZx71ZsbhVxOCRbgktZEPe4SQy7s9Z50uPp0F/f7iw2XmkHN2xkgbMfckMDA==", + "integrity": "sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg=", "dev": true }, "bcrypt-pbkdf": { @@ -37044,7 +37047,7 @@ "has-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-glob/-/has-glob-1.0.0.tgz", - "integrity": "sha512-D+8A457fBShSEI3tFCj65PAbT++5sKiFtdCdOam0gnfBgw9D277OERk+HM9qYJXmdVLZ/znez10SqHN0BBQ50g==", + "integrity": "sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc=", "dev": true, "requires": { "is-glob": "^3.0.0" @@ -37053,7 +37056,7 @@ "is-glob": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", "dev": true, "requires": { "is-extglob": "^2.1.0" @@ -38940,7 +38943,7 @@ "is-window": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz", - "integrity": "sha512-uj00kdXyZb9t9RcAUAwMZAnkBUwdYGhYlt7djMXhfyhUCzwNba50tIiBKR7q0l7tdoBtFVw/3JmLY6fI3rmZmg==", + "integrity": "sha1-LIlspT25feRdPDMTOmXYyfVjSA0=", "dev": true }, "is-windows": { @@ -42317,7 +42320,7 @@ "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=", "dev": true }, "js-tokens": { @@ -47250,7 +47253,7 @@ "num2fraction": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", - "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", "dev": true }, "number-is-nan": { @@ -48728,7 +48731,7 @@ "p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", - "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", "dev": true }, "p-event": { @@ -50302,7 +50305,7 @@ "pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, "prismjs": { @@ -52608,7 +52611,7 @@ "relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "dev": true }, "remark": { diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index e90f7aaf4619f4..739f89b22247fa 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -44,6 +44,7 @@ "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", + "@wordpress/experiments": "file:../experiments", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index c32d929392d8d8..79b386d708b542 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -41,6 +41,7 @@ import BlockHtml from './block-html'; import { useBlockProps } from './use-block-props'; import { store as blockEditorStore } from '../../store'; import { useLayout } from './layout'; +import { unlock } from '../../experiments'; export const BlockListBlockContext = createContext(); /** @@ -113,10 +114,9 @@ function BlockListBlock( { !! __unstableGetContentLockingParent( clientId ); return { themeSupportsLayout: getSettings().supportsLayout, - isContentBlock: - select( blocksStore ).__experimentalHasContentRoleAttribute( - name - ), + isContentBlock: unlock( + select( blocksStore ) + ).__experimentalHasContentRoleAttribute( name ), hasContentLockedParent: _hasContentLockedParent, isContentLocking: getTemplateLock( clientId ) === 'contentOnly' && diff --git a/packages/block-editor/src/experiments.js b/packages/block-editor/src/experiments.js new file mode 100644 index 00000000000000..d62c488ee1f8ac --- /dev/null +++ b/packages/block-editor/src/experiments.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; + +export const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/block-editor' +); diff --git a/packages/blocks/package.json b/packages/blocks/package.json index b67f0e6266e056..f294faf6e1cd08 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -37,6 +37,7 @@ "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", + "@wordpress/experiments": "file:../experiments", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/blocks/src/api/raw-handling/test/utils.js b/packages/blocks/src/api/raw-handling/test/utils.js index 56c38bbb38c965..be0d73c22e9dce 100644 --- a/packages/blocks/src/api/raw-handling/test/utils.js +++ b/packages/blocks/src/api/raw-handling/test/utils.js @@ -10,6 +10,20 @@ import { getBlockContentSchemaFromTransforms, isPlain } from '../utils'; import { store as mockStore } from '../../../store'; import { STORE_NAME as mockStoreName } from '../../../store/constants'; +jest.mock( '@wordpress/experiments', () => { + return { + __dangerousOptInToUnstableAPIsOnlyForCoreModules: () => { + return { + unlock: () => { + return { + registerPrivateActions: () => {}, + registerPrivateSelectors: () => {}, + }; + }, + }; + }, + }; +} ); jest.mock( '@wordpress/data', () => { return { select: jest.fn( ( store ) => { diff --git a/packages/blocks/src/experiments.js b/packages/blocks/src/experiments.js new file mode 100644 index 00000000000000..4d50653f0b76e4 --- /dev/null +++ b/packages/blocks/src/experiments.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; +import { experiments as dataExperiments } from '@wordpress/data'; + +export const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/blocks' +); + +export const { registerPrivateSelectors } = unlock( dataExperiments ); diff --git a/packages/blocks/src/store/index.js b/packages/blocks/src/store/index.js index 6d2dc7c822dbde..a47b26a6f24dbb 100644 --- a/packages/blocks/src/store/index.js +++ b/packages/blocks/src/store/index.js @@ -11,6 +11,9 @@ import * as selectors from './selectors'; import * as actions from './actions'; import { STORE_NAME } from './constants'; +import { registerPrivateSelectors } from '../experiments'; +const { __experimentalHasContentRoleAttribute, ...stableSelectors } = selectors; + /** * Store definition for the blocks namespace. * @@ -20,8 +23,10 @@ import { STORE_NAME } from './constants'; */ export const store = createReduxStore( STORE_NAME, { reducer, - selectors, + selectors: stableSelectors, actions, } ); +registerPrivateSelectors( store, { __experimentalHasContentRoleAttribute } ); + register( store ); diff --git a/packages/data/README.md b/packages/data/README.md index d45a71d3fa6031..a3dd185a4791e7 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -508,6 +508,26 @@ _Returns_ - `Object`: Object containing the action creators. +### experiments + +The experimental APIs exposed by the `@wordpress/data` package. +Only available to core packages. These APIs are not stable and may +change without notice. Do not use outside of core. + +_Usage_ + +```js +import { unlock } from '../experiments'; +import { experiments as dataExperiments } from '@wordpress/data'; +const { registerPrivateSelectors } = unlock( dataExperiments ); + +import { store as blockEditorStore } from './store'; +import { __unstableSelectionHasUnmergeableBlock } from './store/selectors'; +registerPrivateSelectors( store, { + __experimentalHasContentRoleAttribute, +} ); +``` + ### plugins Object of available plugins to use with a registry. diff --git a/packages/data/package.json b/packages/data/package.json index 8b14c1f75a6c72..c805dda03d47b4 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -32,6 +32,7 @@ "@wordpress/compose": "file:../compose", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", + "@wordpress/experiments": "file:../experiments", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/priority-queue": "file:../priority-queue", "@wordpress/redux-routine": "file:../redux-routine", diff --git a/packages/data/src/experiments.js b/packages/data/src/experiments.js new file mode 100644 index 00000000000000..5ef5aca31c20a5 --- /dev/null +++ b/packages/data/src/experiments.js @@ -0,0 +1,104 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/data' + ); + +/** + * Enables registering private actions and selectors on a store without exposing + * them as public API. + * + * Use it with the store descriptor object: + * + * ```js + * const store = createReduxStore( 'my-store', { ... } ); + * registerPrivateActionsAndSelectors( store, { + * __experimentalAction: ( state ) => state.value, + * }, { + * __experimentalSelector: ( state ) => state.value, + * } ); + * ``` + * + * Once the selectors are registered, they can be accessed using the + * `unlock()` utility: + * + * ```js + * unlock( registry.dispatch( blockEditorStore ) ).__experimentalAction(); + * unlock( registry.select( blockEditorStore ) ).__experimentalSelector(); + * ``` + * + * Note the objects returned by select() and dispatch() have the good old public + * methods, but the modules that opted-in to the private APIs can also "unlock" + * additional private selectors and actions. + * + * @example + * + * ```js + * // In the package exposing the private selectors: + * + * import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; + * export const { lock, unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( /* ... *\/ ); + * + * import { experiments as dataExperiments } from '@wordpress/data'; + * const { registerPrivateActionsAndSelectors } = unlock( dataExperiments ); + * + * const store = registerStore( 'my-store', { /* ... *\/ } ); + * registerPrivateActionsAndSelectors( store, { + * __experimentalAction: ( state ) => state.value, + * }, { + * __experimentalSelector: ( state ) => state.value, + * } ); + * + * // In the package using the private selectors: + * import { store as blockEditorStore } from '@wordpress/block-editor'; + * unlock( registry.select( blockEditorStore ) ).__experimentalSelector(); + * + * // Or in a React component: + * useSelect( ( select ) => ( + * unlock( select( blockEditorStore ) ).__experimentalSelector() + * ) ); + * useDispatch( ( dispatch ) => { + * unlock( dispatch( blockEditorStore ) ).__experimentalAction() + * } ); + * ``` + * + * @param {Object} store The store descriptor to register the private selectors on. + * @param {Object} actions The private actions to register in a { actionName: ( ...args ) => action } format. + * @param {Object} selectors The private selectors to register in a { selectorName: ( state, ...args ) => data } format. + */ +export function registerPrivateActionsAndSelectors( + store, + actions = {}, + selectors = {} +) { + lock( store, { actions, selectors } ); +} + +/** + * The experimental APIs exposed by the `@wordpress/data` package. + * Only available to core packages. These APIs are not stable and may + * change without notice. Do not use outside of core. + * + * @example + * + * ```js + * import { unlock } from '../experiments'; + * import { experiments as dataExperiments } from '@wordpress/data'; + * const { registerPrivateSelectors } = unlock( dataExperiments ); + * + * import { store as blockEditorStore } from './store'; + * import { __unstableSelectionHasUnmergeableBlock } from './store/selectors'; + * registerPrivateSelectors( store, { + * __experimentalHasContentRoleAttribute + * } ); + * ``` + */ +export const experiments = {}; +lock( experiments, { + registerPrivateActionsAndSelectors, +} ); diff --git a/packages/data/src/index.js b/packages/data/src/index.js index 91675d48c0354d..330acb3028c5f9 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -30,6 +30,8 @@ export { createRegistrySelector, createRegistryControl } from './factory'; export { controls } from './controls'; export { default as createReduxStore } from './redux-store'; +export { experiments } from './experiments'; + /** * Object of available plugins to use with a registry. * diff --git a/packages/data/src/redux-store/index.js b/packages/data/src/redux-store/index.js index 90e270d56c91ce..798eb15b43e0b7 100644 --- a/packages/data/src/redux-store/index.js +++ b/packages/data/src/redux-store/index.js @@ -11,6 +11,7 @@ import EquivalentKeyMap from 'equivalent-key-map'; */ import createReduxRoutineMiddleware from '@wordpress/redux-routine'; import { compose } from '@wordpress/compose'; +import { createExperiment, configureLockTarget } from '@wordpress/experiments'; /** * Internal dependencies @@ -99,7 +100,8 @@ function createResolversCache() { * @return {StoreDescriptor>} Store Object. */ export default function createReduxStore( key, options ) { - return { + const storeExperiment = createExperiment(); + const storeDescriptor = { name: key, instantiate: ( registry ) => { const reducer = options.reducer; @@ -139,6 +141,19 @@ export default function createReduxStore( key, options ) { }, store ); + configureLockTarget( actions, { + experiment: storeExperiment, + map: ( privateData ) => { + if ( ! privateData?.actions ) { + throw new Error( + `Tried to unlock experimental actions on the ${ key } store where ` + + `no experimental actions were defined. Did you forget to call ` + + `registerPrivateActions()?` + ); + } + return mapActions( privateData.actions, store ); + }, + } ); let selectors = mapSelectors( { @@ -159,6 +174,28 @@ export default function createReduxStore( key, options ) { }, store ); + configureLockTarget( selectors, { + experiment: storeExperiment, + map: ( privateData ) => { + if ( ! privateData?.selectors ) { + throw new Error( + `Tried to unlock experimental selectors on the ${ key } store where ` + + `no experimental selectors were defined. Did you forget to call ` + + `registerPrivateSelectors()?` + ); + } + return mapSelectors( + mapValues( + privateData.selectors, + ( selector ) => + ( state, ...args ) => + selector( state.root, ...args ) + ), + store + ); + }, + } ); + if ( options.resolvers ) { const result = mapResolvers( options.resolvers, @@ -217,6 +254,9 @@ export default function createReduxStore( key, options ) { }; }, }; + configureLockTarget( storeDescriptor, { experiment: storeExperiment } ); + + return storeDescriptor; } /** diff --git a/packages/data/src/test/privateAPIs.js b/packages/data/src/test/privateAPIs.js new file mode 100644 index 00000000000000..e1d41f24894885 --- /dev/null +++ b/packages/data/src/test/privateAPIs.js @@ -0,0 +1,221 @@ +/** + * Internal dependencies + */ +import { createRegistry } from '../registry'; +import createReduxStore from '../redux-store'; +import { experiments as dataExperiments, unlock } from '../experiments'; + +/** + * WordPress dependencies + */ +const { registerPrivateActionsAndSelectors } = unlock( dataExperiments ); + +beforeEach( () => { + jest.useFakeTimers( 'legacy' ); +} ); + +afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +} ); + +describe( 'Private data APIs', () => { + let registry; + + beforeEach( () => { + registry = createRegistry(); + } ); + + function getPublicPrice( state ) { + return state.price; + } + function getSecretDiscount( state ) { + return state.secretDiscount; + } + function setSecretDiscount( price ) { + return { type: 'SET_PRIVATE_PRICE', price }; + } + + function setPublicPrice( price ) { + return { type: 'SET_PUBLIC_PRICE', price }; + } + function createStore() { + const groceryStore = createReduxStore( 'grocer', { + selectors: { + getPublicPrice, + getState: ( state ) => state, + }, + actions: { setPublicPrice }, + reducer: ( state, action ) => { + if ( action?.type === 'SET_PRIVATE_PRICE' ) { + return { + ...state, + secretDiscount: action?.price, + }; + } + return { + price: 1000, + secretDiscount: 800, + ...( state || {} ), + }; + }, + } ); + registry.register( groceryStore ); + return groceryStore; + } + + describe( 'private selectors', () => { + it( 'should expose public selectors by default', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + {}, + { getSecretDiscount } + ); + + const publicSelectors = registry.select( groceryStore ); + expect( publicSelectors.getPublicPrice ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should not expose private selectors by default', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + {}, + { getSecretDiscount } + ); + + const publicSelectors = registry.select( groceryStore ); + expect( publicSelectors.getSecretDiscount ).toEqual( undefined ); + } ); + + it( 'should make private selectors available via unlock()', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + {}, + { getSecretDiscount } + ); + + const privateSelectors = unlock( registry.select( groceryStore ) ); + expect( privateSelectors.getSecretDiscount ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should give private selectors access to the state', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + {}, + { getSecretDiscount } + ); + + const privateSelectors = unlock( registry.select( groceryStore ) ); + expect( privateSelectors.getSecretDiscount() ).toEqual( 800 ); + } ); + + it( 'should throw a clear error when no private selectors are found in the unlock() call', () => { + const groceryStore = createStore(); + + // Forgot to wrap the `getSecretDiscount` in a { selectors: {} } object. + + expect( () => + unlock( registry.select( groceryStore ) ) + ).toThrowError( /no experimental selectors were defined/ ); + } ); + } ); + + describe( 'private actions', () => { + it( 'should expose public actions by default', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( groceryStore, { + setSecretDiscount, + } ); + const publicActions = registry.dispatch( groceryStore ); + expect( publicActions.setPublicPrice ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should not expose private actions by default', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( groceryStore, { + setSecretDiscount, + } ); + const publicActions = registry.dispatch( groceryStore ); + expect( publicActions.setSecretDiscount ).toEqual( undefined ); + } ); + + it( 'should make private actions available via unlock)', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( groceryStore, { + setSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + expect( privateActions.setSecretDiscount ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should work with both private actions and private selectors at the same time', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + { setSecretDiscount }, + { getSecretDiscount } + ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + const privateSelectors = unlock( registry.select( groceryStore ) ); + expect( privateActions.setSecretDiscount ).toEqual( + expect.any( Function ) + ); + expect( privateSelectors.getSecretDiscount ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should dispatch private actions like regular actions', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( groceryStore, { + setSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setSecretDiscount( 400 ); + expect( + registry.select( groceryStore ).getState().secretDiscount + ).toEqual( 400 ); + } ); + + it( 'should dispatch private action thunks like regular actions', () => { + const groceryStore = createStore(); + registerPrivateActionsAndSelectors( + groceryStore, + { + setSecretDiscountThunk: + ( price ) => + ( { dispatch } ) => { + dispatch( { type: 'SET_PRIVATE_PRICE', price } ); + }, + }, + { getSecretDiscount } + ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setSecretDiscountThunk( 100 ); + expect( + unlock( registry.select( groceryStore ) ).getSecretDiscount() + ).toEqual( 100 ); + } ); + + it( 'should throw a clear error when no private actions are found in the unlock() call', () => { + const groceryStore = createStore(); + // Forgot to wrap the `setSecretDiscount` in an { actions: {} } object. + + expect( () => + unlock( registry.dispatch( groceryStore ) ) + ).toThrowError( /no experimental actions were defined/ ); + } ); + } ); +} ); diff --git a/packages/experiments/src/implementation.js b/packages/experiments/src/implementation.js index 11811dfcffaf12..7cba3845e400bf 100644 --- a/packages/experiments/src/implementation.js +++ b/packages/experiments/src/implementation.js @@ -102,13 +102,14 @@ export const __dangerousOptInToUnstableAPIsOnlyForCoreModules = ( * @param {any} privateData The private data to bind to the object. */ function lock( object, privateData ) { - const { id } = getLockingConfig( object ); - lockedData.set( id, { + const { experiment } = getLockTargetConfig( object ); + lockedData.set( experiment, { privateData, - decorated: false, } ); } +const identity = ( x ) => x; + /** * Unlocks the private data bound to an object. * @@ -133,90 +134,74 @@ function lock( object, privateData ) { * @return {any} The private data bound to the object. */ function unlock( object ) { - const { id, onFirstUnlock } = getLockingConfig( object ); + const { experiment, map = identity } = getLockTargetConfig( object ); - const lockedEntry = lockedData.get( id ) || { + const lockedEntry = lockedData.get( experiment ) || { privateData: null, - decorated: false, }; - let { privateData, decorated } = lockedEntry; - if ( onFirstUnlock && ! decorated ) { - privateData = onFirstUnlock( privateData ); - lockedData.set( id, { - privateData, - decorated: true, - } ); - } - return privateData; + return map( lockedEntry.privateData ); } const lockedData = new WeakMap(); + +/** + * Used by configureLockTarget() to store the experiment configuration + * inside lock/unlock targets. + */ +const __lockTargetConfig = Symbol( 'Lock Target' ); + /** - * Use this symbol to tell `lock()` and `unlock()` that - * the private data should be paired with a custom object. + * Configures the locking and unlocking process of a given object. See + * the examples and `config` parameter below for more details. * * @example * ```js - * const customExperimentId = {}; - * const object1 = { [ experimentId ]: customExperimentId } - * const object2 = { [ experimentId ]: customExperimentId } - * ; - * const privateData = { a: 1 }; - * lock( object1, privateData ); + * const experiment = createExperiment(); + * // Use the same experiment for two objects: + * const object1 = {}; + * configureLockTarget( object1, { experiment } ); * - * unlock( object1 ); - * // { a: 1 } + * const object2 = {}; + * configureLockTarget( object2, { experiment } ); * - * unlock( object2 ); - * // { a: 1 } + * // Lock the first object: + * lock( object1, 'sh' ); + * + * // The private data can be accessed via both objects: + * unlock( object1 ) // "sh" + * unlock( object2 ) // "sh" + * + * // The configuration is preserved through cloning: + * const object3 = { ...object1 }; + * unlock( object3 ) // "sh" * ``` - */ -export const experimentId = Symbol( 'Experiments' ); -export const isExperimentsConfig = Symbol( 'ExperimentsConfig' ); - -function getLockingConfig( object ) { - if ( ! ( experimentId in object ) ) { - return { - id: object, - }; - } - const configOrId = object[ experimentId ]; - if ( isExperimentsConfig in configOrId ) { - return configOrId; - } - return { - id: configOrId, - }; -} - -/** - * Configure the unlocking process. * * @example * ```js - * const object = {} - * configureExperiment( object, { - * onFirstUnlock: ( temperature ) => temperature * 2 - * } ); - * - * lock(object, 2); - * - * unlock( object ); - * // 4 + * const experiment = createExperiment(); + * // Use the same experiment for two objects: + * const object = {}; + * configureLockTarget( object, { + * map( privateData ) { + * return privateData.toUpperCase(); + * }, + * } ); * - * unlock( object ); - * // 4 + * lock( object1, 'private' ); + * unlock( object1 ) // "PRIVATE" * ``` * - * @param {any} object The locked object. - * @param {Object} config The unlocking config. - * @param {Function} [config.onFirstUnlock] A function to run on the unlocked data when `unlock()` - * is called for the first time. + * @param {any} lockTarget The object that will later be passed to `lock()` and `unlock()`. + * @param {Object} config The locking configuration. + * @param {Function} [config.experiment] The experiment to use for locking/unlocking, see `createExperiment()`. + * If two objects use the same experiment, they will share the same private data. + * @param {Function} [config.map] A function to map the private data when `unlock()` is called. + * It receives the private data as its only argument and returns + * the updated private data. */ -export function configureExperiment( object, config ) { - const id = getExperimentId( object ); - const { onFirstUnlock, ...rest } = config; +export function configureLockTarget( lockTarget, config ) { + const { map, experiment = createExperiment(), ...rest } = config; if ( Object.entries( rest ).length ) { throw new Error( `Unknown options provided to configureLockingBehavior: ${ Object.keys( @@ -224,29 +209,32 @@ export function configureExperiment( object, config ) { ).join( ', ' ) }` ); } - object[ experimentId ] = { - ...config, - id, - [ isExperimentsConfig ]: true, + lockTarget[ __lockTargetConfig ] = { + experiment, + map, }; } -function getExperimentId( object ) { - return getLockingConfig( object ).id; -} - /** - * Returns a new unique object that can be used in conjunction - * with `experimentId` as an experiment ID for `lock()` and `unlock()`. + * Creates a new experiment with unique identity. * - * @see experimentId - * - * @return {Object} A new unique object. + * @return {Object} A new experiment object. */ -export function makeExperimentId() { +export function createExperiment() { + // This is a simple object with a unique identity. + // It's used to identify the private data associated with a given object. + // See `configureLockTarget()` and `lock()` for more details. return {}; } +function getLockTargetConfig( object ) { + return ( + object[ __lockTargetConfig ] || { + experiment: object, + } + ); +} + // Unit tests utilities: /** diff --git a/packages/experiments/src/index.js b/packages/experiments/src/index.js index bc0982952f1440..d9c1855b4dce31 100644 --- a/packages/experiments/src/index.js +++ b/packages/experiments/src/index.js @@ -1,6 +1,5 @@ export { __dangerousOptInToUnstableAPIsOnlyForCoreModules, - configureExperiment, - experimentId, - makeExperimentId, + configureLockTarget, + createExperiment, } from './implementation'; diff --git a/packages/experiments/src/test/index.js b/packages/experiments/src/test/index.js index a8eb4dae0d922f..32fb7dd5def8b9 100644 --- a/packages/experiments/src/test/index.js +++ b/packages/experiments/src/test/index.js @@ -6,9 +6,8 @@ import { resetRegisteredExperiments, resetAllowedCoreModules, allowCoreModule, - experimentId, - makeExperimentId, - configureExperiment, + createExperiment, + configureLockTarget, } from '../implementation'; beforeEach( () => { @@ -135,7 +134,7 @@ describe( 'lock(), unlock()', () => { } ); } ); -describe( 'advanced lock() and unlock()', () => { +describe( 'configureLockTarget()', () => { let lock, unlock; beforeEach( () => { // This would live in @experiments/test: @@ -148,37 +147,48 @@ describe( 'advanced lock() and unlock()', () => { unlock = experimentsAPI.unlock; } ); - it( 'Should assign the private data not to the object, but to the object under `experimentId`', () => { - const thisExperimentId = makeExperimentId(); - const object1 = { - [ experimentId ]: thisExperimentId, - }; - const object2 = { - [ experimentId ]: thisExperimentId, - }; - lock( object1, 'sh' ); - expect( unlock( object1 ) ).toBe( 'sh' ); - expect( unlock( object2 ) ).toBe( 'sh' ); - } ); + it( 'Should return the same private data upon unlocking two lock targets sharing the same experiment', () => { + const thisExperiment = createExperiment(); + const object1 = {}; + configureLockTarget( object1, { + experiment: thisExperiment, + } ); + + const object2 = {}; + configureLockTarget( object2, { + experiment: thisExperiment, + } ); - it( 'configureExperiment() should preserve the `experimentId`', () => { - const thisExperimentId = makeExperimentId(); - const object1 = { - [ experimentId ]: thisExperimentId, - }; - const object2 = { - [ experimentId ]: thisExperimentId, - }; - configureExperiment( object2, {} ); lock( object1, 'sh' ); expect( unlock( object1 ) ).toBe( 'sh' ); expect( unlock( object2 ) ).toBe( 'sh' ); } ); - it( 'Should pass the locked data through onFirstUnlock (specified via configureExperiment()) on the first unlock()', () => { + it( + 'Should return the same private data upon unlocking two lock targets sharing the same experiment ' + + 'even when unlocking a cloned lock target', + () => { + const thisExperiment = createExperiment(); + const object1 = {}; + configureLockTarget( object1, { + experiment: thisExperiment, + } ); + + const object2 = {}; + configureLockTarget( object2, { + experiment: thisExperiment, + } ); + + lock( object1, 'sh' ); + expect( unlock( object1 ) ).toBe( 'sh' ); + expect( unlock( { ...object2 } ) ).toBe( 'sh' ); + } + ); + + it( 'Should pass the locked data through map on the first unlock()', () => { const object = {}; - configureExperiment( object, { - onFirstUnlock( secretString ) { + configureLockTarget( object, { + map( secretString ) { return `Decorated: ${ secretString }`; }, } ); @@ -186,33 +196,33 @@ describe( 'advanced lock() and unlock()', () => { expect( unlock( object ) ).toBe( 'Decorated: sh' ); } ); - it( 'Should pass null through onFirstUnlock (specified via configureExperiment()) if there is no private data available on the first unlock()', () => { + it( 'Should pass null through map if there is no private data available on the first unlock()', () => { const object = {}; - configureExperiment( object, { - onFirstUnlock( secretString ) { + configureLockTarget( object, { + map( secretString ) { return `Decorated: ${ secretString }`; }, } ); expect( unlock( object ) ).toBe( 'Decorated: null' ); } ); - it( 'Should pass the locked data through onFirstUnlock (specified via configureExperiment()) on the first unlock() – when experimentId is specified', () => { - const thisExperimentId = makeExperimentId(); - const object1 = { - [ experimentId ]: thisExperimentId, - }; - const object2 = { - [ experimentId ]: thisExperimentId, - }; - configureExperiment( object2, { - onFirstUnlock( secretString ) { + it( 'Should pass the locked data through map on the first unlock() even when the experiment is specified', () => { + const thisExperiment = createExperiment(); + const object1 = {}; + const object2 = {}; + configureLockTarget( object1, { + experiment: thisExperiment, + } ); + configureLockTarget( object2, { + experiment: thisExperiment, + map( secretString ) { return `Decorated: ${ secretString }`; }, } ); lock( object1, 'sh' ); expect( unlock( object1 ) ).toBe( 'sh' ); expect( unlock( object2 ) ).toBe( 'Decorated: sh' ); - expect( unlock( object1 ) ).toBe( 'Decorated: sh' ); + expect( unlock( object1 ) ).toBe( 'sh' ); } ); } );