From 1b1810456daf24439fa4745165855d3f4592ecb1 Mon Sep 17 00:00:00 2001 From: Adam Zielinski Date: Tue, 17 Jan 2023 22:06:38 +0100 Subject: [PATCH] Experiments: sharing private APIs with lock() and unlock() (#46131) ## Description This commit introduces a more convenient API for managing the private experiments as the former `register`-based API was quite cumbersome to use. The idea is to "lock" private data inside public objects: ```js const { lock, unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( '', '@wordpress/blocks' ); export const publicObject = {}; lock( __experiments, "Shh, private data!" ); publicObject // {} unlock( publicObject ) // "Shh, private data!" ``` This new `lock()`/`unlock()` API enables private functions, classes, components, selectors, actions, arguments, and properties. Any package that opted-in to private APIs can call `unlock()` on publicly available artifacts to retrieve the related private API. Kudos to @jsnajdr for [identifying an opportunity to simplify the API](https://github.com/WordPress/gutenberg/pull/44521#discussion_r983306757)! ## Examples ### Private selectors: ```js // In wordpress/block-data: import { store as blockEditorStore } from './store'; import { unlock } from '../experiments'; import { __unstableSelectionHasUnmergeableBlock } from './store/selectors'; unlock( store ).registerPrivateSelectors( { __unstableSelectionHasUnmergeableBlock } ); // In a React component: function MyComponent() { const hasRole = useSelect( ( select ) => ( unlock( select( blockEditorStore ) ).__unstableSelectionHasUnmergeableBlock() ) ); // ... } ``` ### Private functions, classes, and variables ```js // In packages/package1/index.js: import { lock } from './experiments'; export const experiments = {}; /* Attach private data to the exported object */ lock(experiments, { __experimentalCallback: function() {}, __experimentalReactComponent: function ExperimentalComponent() { return
; }, __experimentalClass: class Experiment{}, __experimentalVariable: 5, }); // In packages/package2/index.js: import { experiments } from '@wordpress/package1'; import { unlock } from './experiments'; const { __experimentalCallback, __experimentalReactComponent, __experimentalClass, __experimentalVariable } = unlock( experiments ); ``` ### Private function arguments To add an experimental argument to a stable function you'll need to prepare a stable and an experimental version of that function. Then, export the stable function and `lock()` the unstable function inside it: ```js // In @wordpress/package1/index.js: import { lock } from './experiments'; // The experimental function contains all the logic function __experimentalValidateBlocks(formula, __experimentalIsStrict) { let isValid = false; // ...complex logic we don't want to duplicate... if ( __experimentalIsStrict ) { // ... } // ...complex logic we don't want to duplicate... return isValid; } // The stable public function is a thin wrapper that calls the // experimental function with the experimental features disabled export function validateBlocks(blocks) { __experimentalValidateBlocks(blocks, false); } lock( validateBlocks, __experimentalValidateBlocks ); // In @wordpress/package2/index.js: import { validateBlocks } from '@wordpress/package1'; import { unlock } from './experiments'; // The experimental function may be "unlocked" given the stable function: const __experimentalValidateBlocks = unlock(validateBlocks); __experimentalValidateBlocks(blocks, true); ``` ### Private React Component properties To add an experimental argument to a stable component you'll need to prepare a stable and an experimental version of that component. Then, export the stable function and `lock()` the unstable function inside it: ```js // In @wordpress/package1/index.js: import { lock } from './experiments'; // The experimental component contains all the logic const ExperimentalMyButton = ( { title, __experimentalShowIcon = true } ) => { // ...complex logic we don't want to duplicate... return ( ); } // The stable public component is a thin wrapper that calls the // experimental component with the experimental features disabled export const MyButton = ( { title } ) => lock(MyButton, ExperimentalMyButton); // In @wordpress/package2/index.js: import { MyButton } from '@wordpress/package1'; import { unlock } from './experiments'; // The experimental component may be "unlocked" given the stable component: const ExperimentalMyButton = unlock(MyButton); export function MyComponent() { return ( ) } ``` Co-authored-by: Ramon Co-authored-by: Robert Anderson --- docs/contributors/code/coding-guidelines.md | 310 +++++++++++++++++++- package-lock.json | 23 +- packages/data/package.json | 1 + packages/data/src/experiments.js | 10 + packages/data/src/redux-store/index.js | 46 ++- packages/data/src/test/privateAPIs.js | 228 ++++++++++++++ packages/experiments/README.md | 90 ++++-- packages/experiments/src/implementation.js | 187 ++++++++++++ packages/experiments/src/index.js | 86 +----- packages/experiments/src/test/index.js | 283 ++++++++++++++++-- 10 files changed, 1113 insertions(+), 151 deletions(-) create mode 100644 packages/data/src/experiments.js create mode 100644 packages/data/src/test/privateAPIs.js create mode 100644 packages/experiments/src/implementation.js diff --git a/docs/contributors/code/coding-guidelines.md b/docs/contributors/code/coding-guidelines.md index 556833ca89b7ee..9fc115b8eb9e3a 100644 --- a/docs/contributors/code/coding-guidelines.md +++ b/docs/contributors/code/coding-guidelines.md @@ -136,10 +136,318 @@ export { __unstableDoTerribleAwfulAction } from './api'; - An **experimental API** is one which is planned for eventual public availability, but is subject to further experimentation, testing, and discussion. - An **unstable API** is one which serves as a means to an end. It is not desired to ever be converted into a public API. -In both cases, the API should be made stable or removed at the earliest opportunity. +In both cases, the API should be made stable or removed at the earliest opportunity. While an experimental API may often stabilize into a publicly-available API, there is no guarantee that it will. The conversion to a stable API will inherently be considered a breaking change by the mere fact that the function name must be changed to remove the `__experimental` prefix. +#### Experimental APIs merged into WordPress Core become a liability + +**Avoid introducing public experimental APIs.** + +As of June 2022, WordPress Core contains 280 publicly exported experimental APIs. They got merged from the Gutenberg +plugin during the major WordPress releases. Many plugins and themes rely on these experimental APIs for essential +features that can't be accessed in any other way. Naturally, these APIs can't be removed without a warning anymore. +They are a part of the WordPress public API and fall under the +[WordPress Backwards Compatibility policy](https://developer.wordpress.org/block-editor/contributors/code/backward-compatibility/). +Removing them involves a deprecation process. It may be relatively easy for some APIs, but it may require effort and +span multiple WordPress releases for others. + +**Use private experimental APIs instead.** + +Make your experimental APIs private and don't expose them to WordPress extenders. + +This way they'll remain internal implementation details that can be changed or removed + without a warning and without breaking WordPress plugins. + +The tactical guidelines below will help you write code without introducing new experimental APIs. + +#### General guidelines + +Some `__experimental` functions are exported in *package A* and only used in a single *package B* and nowhere else. Consider removing such functions from *package A* and making them private and non-exported members of *package B*. + +If your experimental API is only meant for the Gutenberg Plugin but not for the next WordPress major release, consider limiting the export to the plugin environment. For example, `@wordpress/components` could do that to receive early feedback about a new Component, but avoid bringing that component to WordPress core: + +```js +if ( IS_GUTENBERG_PLUGIN ) { + export { __experimentalFunction } from './experiments'; +} +``` + +#### Replace experimental selectors with hooks + +Sometimes a non-exported React hook suffices as a substitute for introducing a new experimental selectors: + +```js +// Instead of this: + // selectors.js: + export function __unstableHasActiveBlockOverlayActive( state, parent ) { /* ... */ } + export function __unstableIsWithinBlockOverlay( state, clientId ) { + let parent = state.blocks.parents[ clientId ]; + while ( !! parent ) { + if ( __unstableHasActiveBlockOverlayActive( state, parent ) ) { + return true; + } + parent = state.blocks.parents[ parent ]; + } + return false; + } + // MyComponent.js: + function MyComponent({ clientId }) { + const { __unstableIsWithinBlockOverlay } = useSelect( myStore ); + const isWithinBlockOverlay = __unstableIsWithinBlockOverlay( clientId ); + // ... + } + +// Consider this: + // MyComponent.js: + function hasActiveBlockOverlayActive ( selectors, parent ) { /* ... */ } + function useIsWithinBlockOverlay( clientId ) { + return useSelect( ( select ) => { + const selectors = select( blockEditorStore ); + let parent = selectors.getBlockRootClientId( clientId ); + while ( !!parent ) { + if ( hasActiveBlockOverlayActive( selectors, parent ) ) { + return true; + } + parent = selectors.getBlockRootClientId( parent ); + } + return false; + }); + } + function MyComponent({ clientId }) { + const isWithinBlockOverlay = useIsWithinBlockOverlay( clientId ); + // ... + } +``` + +#### Dispatch experimental actions in thunks + +Turning an existing public action into a [thunk](/docs/how-to-guides/thunks.md) +enables dispatching private actions inline: + +```js +export function toggleFeature( scope, featureName ) { + return function ( { dispatch } ) { + dispatch({ type: '__experimental_BEFORE_TOGGLE' }) + // ... + }; +} +``` + +#### Use the `lock()` and `unlock()` API from `@wordpress/experiments` to privately export almost anything + +Each `@wordpress` package wanting to privately access or expose experimental APIs can +do so by opting-in to `@wordpress/experiments`: + +```js +// In packages/block-editor/experiments.js: +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/block-editor' // Name of the package calling __dangerousOptInToUnstableAPIsOnlyForCoreModules, + // (not the name of the package whose APIs you want to access) + ); +``` + +Each `@wordpress` package may only opt-in once. The process clearly communicates the extenders are not supposed +to use it. This document will focus on the usage examples, but you can [find out more about the `@wordpress/experiments` package in the its README.md](/packages/experiments/README.md). + +Once the package opted-in, you can use the `lock()` and `unlock()` utilities: + +```js +// Say this object is exported from a package: +export const publicObject = {}; + +// However, this string is internal and should not be publicly available: +const __experimentalString = '__experimental information'; + +// Solution: lock the string "inside" of the object: +lock( publicObject, __experimentalString ); + +// The string is not nested in the object and cannot be extracted from it: +console.log( publicObject ); +// {} + +// The only way to access the string is by "unlocking" the object: +console.log( unlock( publicObject ) ); +// "__experimental information" + +// lock() accepts all data types, not just strings: +export const anotherObject = {}; +lock( anotherObject, function __experimentalFn() {} ); +console.log( unlock( anotherObject ) ); +// function __experimentalFn() {} +``` + +Keep reading to learn how to use `lock()` and `unlock()` to avoid publicly exporting +different kinds of `__experimental` APIs. + +##### Experimental selectors and actions + +You can attach private selectors and actions to a public store: + +```js +// In packages/package1/store.js: +import { experiments as dataExperiments } from '@wordpress/data'; +import { __experimentalHasContentRoleAttribute, ...selectors } from './selectors'; +import { __experimentalToggleFeature, ...actions } from './selectors'; +// The `lock` function is exported from the internal experiments.js file where +// the opt-in function was called. +import { lock, unlock } from './experiments'; + +export const store = registerStore(/* ... */); +// Attach a private action to the exported store: +unlock( store ).registerPrivateActions({ + __experimentalToggleFeature +} ); + +// Attach a private action to the exported store: +unlock( store ).registerPrivateSelectors({ + __experimentalHasContentRoleAttribute +} ); + + +// In packages/package2/MyComponent.js: +import { store } from '@wordpress/package1'; +import { useSelect } from '@wordpress/data'; +// The `unlock` function is exported from the internal experiments.js file where +// the opt-in function was called. +import { unlock } from './experiments'; + +function MyComponent() { + const hasRole = useSelect( ( select ) => ( + // Use the private selector: + unlock( select( store ) ).__experimentalHasContentRoleAttribute() + // Note the unlock() is required. This line wouldn't work: + // select( store ).__experimentalHasContentRoleAttribute() + ) ); + + // Use the private action: + unlock( useDispatch( store ) ).__experimentalToggleFeature(); + + // ... +} +``` + +##### Experimental functions, classes, and variables + +```js +// In packages/package1/index.js: +import { lock } from './experiments'; + +export const experiments = {}; +/* Attach private data to the exported object */ +lock(experiments, { + __experimentalCallback: function() {}, + __experimentalReactComponent: function ExperimentalComponent() { return
; }, + __experimentalClass: class Experiment{}, + __experimentalVariable: 5, +}); + + +// In packages/package2/index.js: +import { experiments } from '@wordpress/package1'; +import { unlock } from './experiments'; + +const { + __experimentalCallback, + __experimentalReactComponent, + __experimentalClass, + __experimentalVariable +} = unlock( experiments ); +``` + +#### Experimental function arguments + +To add an experimental argument to a stable function you'll need +to prepare a stable and an experimental version of that function. +Then, export the stable function and `lock()` the unstable function +inside it: + +```js +// In @wordpress/package1/index.js: +import { lock } from './experiments'; + +// The experimental function contains all the logic +function __experimentalValidateBlocks(formula, __experimentalIsStrict) { + let isValid = false; + // ...complex logic we don't want to duplicate... + if ( __experimentalIsStrict ) { + // ... + } + // ...complex logic we don't want to duplicate... + + return isValid; +} + +// The stable public function is a thin wrapper that calls the +// experimental function with the experimental features disabled +export function validateBlocks(blocks) { + __experimentalValidateBlocks(blocks, false); +} +lock( validateBlocks, __experimentalValidateBlocks ); + + +// In @wordpress/package2/index.js: +import { validateBlocks } from '@wordpress/package1'; +import { unlock } from './experiments'; + +// The experimental function may be "unlocked" given the stable function: +const __experimentalValidateBlocks = unlock(validateBlocks); +__experimentalValidateBlocks(blocks, true); +``` + +#### Experimental React Component properties + +To add an experimental argument to a stable component you'll need +to prepare a stable and an experimental version of that component. +Then, export the stable function and `lock()` the unstable function +inside it: + +```js +// In @wordpress/package1/index.js: +import { lock } from './experiments'; + +// The experimental component contains all the logic +const ExperimentalMyButton = ( { title, __experimentalShowIcon = true } ) => { + // ...complex logic we don't want to duplicate... + + return ( + + ); +} + +// The stable public component is a thin wrapper that calls the +// experimental component with the experimental features disabled +export const MyButton = ( { title } ) => + + +lock(MyButton, ExperimentalMyButton); + + +// In @wordpress/package2/index.js: +import { MyButton } from '@wordpress/package1'; +import { unlock } from './experiments'; + +// The experimental component may be "unlocked" given the stable component: +const ExperimentalMyButton = unlock(MyButton); +export function MyComponent() { + return ( + + ) +} +``` + +#### Experimental block.json and theme.json APIs + +As of today, there is no way to restrict the `block.json` and `theme.json` APIs +to the Gutenberg codebase. In the future, however, the new `__experimental` APIs +will only apply to the core WordPress blocks and plugins and themes will not be +able to access them. + ### Objects When possible, use [shorthand notation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#New_notations_in_ECMAScript_2015) when defining object property values: diff --git a/package-lock.json b/package-lock.json index 6d19ea08a22d9c..c5444ad6963991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17689,6 +17689,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", @@ -19452,7 +19453,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": { @@ -27745,7 +27746,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": { @@ -28117,7 +28118,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": { @@ -36733,7 +36734,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" @@ -36742,7 +36743,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" @@ -38917,7 +38918,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 +42318,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": { @@ -47517,7 +47518,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": { @@ -48986,7 +48987,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": { @@ -50542,7 +50543,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 }, "private": { @@ -53128,7 +53129,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/data/package.json b/packages/data/package.json index 3207c498b74a26..e7ff5a57791a30 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..4417223fb7d973 --- /dev/null +++ b/packages/data/src/experiments.js @@ -0,0 +1,10 @@ +/** + * 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' + ); diff --git a/packages/data/src/redux-store/index.js b/packages/data/src/redux-store/index.js index e5898290a1a795..1ff1faed5695b2 100644 --- a/packages/data/src/redux-store/index.js +++ b/packages/data/src/redux-store/index.js @@ -16,6 +16,7 @@ import { compose } from '@wordpress/compose'; * Internal dependencies */ import { builtinControls } from '../controls'; +import { lock } from '../experiments'; import promise from '../promise-middleware'; import createResolversCacheMiddleware from '../resolvers-cache-middleware'; import createThunkMiddleware from './thunk-middleware'; @@ -108,7 +109,9 @@ function createResolversCache() { * @return {StoreDescriptor>} Store Object. */ export default function createReduxStore( key, options ) { - return { + const privateActions = {}; + const privateSelectors = {}; + const storeDescriptor = { name: key, instantiate: ( registry ) => { const reducer = options.reducer; @@ -148,6 +151,17 @@ export default function createReduxStore( key, options ) { }, store ); + lock( + actions, + new Proxy( privateActions, { + get: ( target, prop ) => { + return ( + mapActions( privateActions, store )[ prop ] || + actions[ prop ] + ); + }, + } ) + ); let selectors = mapSelectors( { @@ -168,6 +182,25 @@ export default function createReduxStore( key, options ) { }, store ); + lock( + selectors, + new Proxy( privateSelectors, { + get: ( target, prop ) => { + return ( + mapSelectors( + mapValues( + privateSelectors, + ( selector ) => + ( state, ...args ) => + selector( state.root, ...args ) + ), + store + )[ prop ] || selectors[ prop ] + ); + }, + } ) + ); + if ( options.resolvers ) { const result = mapResolvers( options.resolvers, @@ -226,6 +259,17 @@ export default function createReduxStore( key, options ) { }; }, }; + + lock( storeDescriptor, { + registerPrivateActions: ( actions ) => { + Object.assign( privateActions, actions ); + }, + registerPrivateSelectors: ( selectors ) => { + Object.assign( privateSelectors, selectors ); + }, + } ); + + return storeDescriptor; } /** diff --git a/packages/data/src/test/privateAPIs.js b/packages/data/src/test/privateAPIs.js new file mode 100644 index 00000000000000..00b9e8d866c213 --- /dev/null +++ b/packages/data/src/test/privateAPIs.js @@ -0,0 +1,228 @@ +/** + * Internal dependencies + */ +import { createRegistry } from '../registry'; +import createReduxStore from '../redux-store'; +import { unlock } from '../experiments'; + +/** + * WordPress dependencies + */ + +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, + }; + } else if ( action?.type === 'SET_PUBLIC_PRICE' ) { + return { + ...state, + price: 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(); + unlock( groceryStore ).registerPrivateSelectors( 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(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + + const publicSelectors = registry.select( groceryStore ); + expect( publicSelectors.getSecretDiscount ).toEqual( undefined ); + } ); + + it( 'should make private selectors available via unlock()', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + + const privateSelectors = unlock( registry.select( groceryStore ) ); + expect( privateSelectors.getSecretDiscount ).toEqual( + expect.any( Function ) + ); + // The public selector is still accessible: + expect( privateSelectors.getPublicPrice ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should give private selectors access to the state', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + + const privateSelectors = unlock( registry.select( groceryStore ) ); + expect( privateSelectors.getSecretDiscount() ).toEqual( 800 ); + } ); + + it( 'should support public selectors accessed via unlock()', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + + const unlockedSelectors = unlock( registry.select( groceryStore ) ); + expect( unlockedSelectors.getPublicPrice() ).toEqual( 1000 ); + } ); + } ); + + describe( 'private actions', () => { + it( 'should expose public actions by default', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( 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(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + const publicActions = registry.dispatch( groceryStore ); + expect( publicActions.setSecretDiscount ).toEqual( undefined ); + } ); + + it( 'should make private actions available via unlock)', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + expect( privateActions.setSecretDiscount ).toEqual( + expect.any( Function ) + ); + // The public action is still accessible: + expect( privateActions.setPublicPrice ).toEqual( + expect.any( Function ) + ); + } ); + + it( 'should work with both private actions and private selectors at the same time', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + unlock( groceryStore ).registerPrivateSelectors( { + 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(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setSecretDiscount( 400 ); + expect( + registry.select( groceryStore ).getState().secretDiscount + ).toEqual( 400 ); + } ); + + it( 'should dispatch public actions on the unlocked store', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setPublicPrice( 400 ); + expect( registry.select( groceryStore ).getState().price ).toEqual( + 400 + ); + } ); + + it( 'should dispatch private action thunks like regular actions', () => { + const groceryStore = createStore(); + unlock( groceryStore ).registerPrivateActions( { + setSecretDiscountThunk: + ( price ) => + ( { dispatch } ) => { + dispatch( { type: 'SET_PRIVATE_PRICE', price } ); + }, + } ); + unlock( groceryStore ).registerPrivateSelectors( { + getSecretDiscount, + } ); + const privateActions = unlock( registry.dispatch( groceryStore ) ); + privateActions.setSecretDiscountThunk( 100 ); + expect( + unlock( registry.select( groceryStore ) ).getSecretDiscount() + ).toEqual( 100 ); + } ); + } ); +} ); diff --git a/packages/experiments/README.md b/packages/experiments/README.md index fe6221588ecc19..a08406f8a8f76d 100644 --- a/packages/experiments/README.md +++ b/packages/experiments/README.md @@ -1,51 +1,91 @@ # Experiments -Private `__experimental` APIs that are not [exposed publicly plugin authors](https://make.wordpress.org/core/2022/08/10/proposal-stop-merging-experimental-apis-from-gutenberg-to-wordpress-core/#respond). +`@wordpress/experiments` enables sharing private `__experimental` APIs across `@wordpress` packages without +[publicly exposing them to WordPress extenders](https://make.wordpress.org/core/2022/08/10/proposal-stop-merging-experimental-apis-from-gutenberg-to-wordpress-core/#respond). -This package acts as a "dealer" that only allows WordPress packages to use the experimental APIs. +## Getting started -Each package needs to start by registering itself: +Every `@wordpress` package wanting to privately access or expose experimental APIs must opt-in to `@wordpress/experiments`: ```js -const { register } = +// In packages/block-editor/experiments.js: +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; +export const { lock, unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( - '', // See index.js, this may change without notice. - '@wordpress/blocks' + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', + '@wordpress/block-editor' // Name of the package calling __dangerousOptInToUnstableAPIsOnlyForCoreModules, + // (not the name of the package whose APIs you want to access) ); ``` -The function name communicates that plugins are not supposed to use it. To make double and triple sure, the first argument must be that exact consent string, and the second argument must be a known `@wordpress` package that hasn't opted in yet – otherwise an error is thrown. +Each package may only opt in once. The function name communicates that plugins are not supposed to use it. -Expose a new `__experimental` API as follows: +The function will throw an error if the following conditions are not met: + +1. The first argument must exactly match the required consent string: `'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.'`. +2. The second argument must be a known `@wordpress` package that hasn't yet opted into `@wordpress/experiments` + +Once the opt-in is complete, the obtained `lock()` and `unlock()` utilities enable hiding `__experimental` APIs from the naked eye: ```js -export const __experiments = register( { __unstableGetInnerBlocksProps } ) +// Say this object is exported from a package: +export const publicObject = {}; + +// However, this string is internal and should not be publicly available: +const __experimentalString = '__experimental information'; + +// Solution: lock the string "inside" of the object: +lock( publicObject, __experimentalString ); + +// The string is not nested in the object and cannot be extracted from it: +console.log( publicObject ); +// {} + +// The only way to access the string is by "unlocking" the object: +console.log( unlock( publicObject ) ); +// "__experimental information" + +// lock() accepts all data types, not just strings: +export const anotherObject = {}; +lock( anotherObject, function __experimentalFn() {} ); +console.log( unlock( anotherObject ) ); +// function __experimentalFn() {} ``` -Consume the registered `__experimental` APIs as follows: +Use `lock()` and `unlock()` to privately distribute the `__experimental` APIs across `@wordpress` packages: ```js -// In the @wordpress/block-editor package: -import { __experiments as blocksExperiments } from '@wordpress/blocks'; -const { unlock } = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - '', // See index.js - '@wordpress/block-editor' - ); - -const { __unstableGetInnerBlocksProps } = unlock( blocksExperiments ); +// In packages/package1/index.js: +import { lock } from './experiments'; + +export const experiments = {}; +/* Attach private data to the exported object */ +lock(experiments, { + __experimentalFunction: function() {}, +}); + +// In packages/package2/index.js: +import { experiments } from '@wordpress/package1'; +import { unlock } from './experiments'; + +const { + __experimentalFunction +} = unlock( experiments ); ``` -All new experimental APIs should be shipped as **private** using this method. +## Shipping experimental APIs + +See the [Experimental and Unstable APIs chapter of Coding Guidelines](/docs/contributors/code/coding-guidelines.md) to learn how `lock()` and `unlock()` can help +you ship private experimental functions, arguments, components, properties, actions, selectors. -The **public** experimental APIs that have already been shipped in a stable WordPress version should remain accessible via `window.wp`. Please do not create new ones. +## Technical limitations A determined developer who would want to use the private experimental APIs at all costs would have to: -* Realize a private importing system exists -* Read the code where the risks would be spelled out in capital letters -* Explicitly type out he or she is aware of the consequences -* Pretend to register a `@wordpress` package (and trigger an error as soon as the real package is loaded) +- Realize a private importing system exists +- Read the code where the risks would be spelled out in capital letters +- Explicitly type out he or she is aware of the consequences +- Pretend to register a `@wordpress` package (and trigger an error as soon as the real package is loaded) Dangerously opting in to using these APIs by theme and plugin developers is not recommended. Furthermore, the WordPress Core philosophy to strive to maintain backward compatibility for third-party developers **does not apply** to experimental APIs registered via this package. diff --git a/packages/experiments/src/implementation.js b/packages/experiments/src/implementation.js new file mode 100644 index 00000000000000..bf333b7e2646f8 --- /dev/null +++ b/packages/experiments/src/implementation.js @@ -0,0 +1,187 @@ +/** + * wordpress/experimental – the utilities to enable private cross-package + * exports of experimental APIs. + * + * This "implementation.js" file is needed for the sake of the unit tests. It + * exports more than the public API of the package to aid in testing. + */ + +/** + * The list of core modules allowed to opt-in to the experimental APIs. + */ +const CORE_MODULES_USING_EXPERIMENTS = [ + '@wordpress/data', + '@wordpress/blocks', + '@wordpress/block-editor', +]; + +/** + * A list of core modules that already opted-in to + * the experiments package. + */ +const registeredExperiments = []; + +/* + * Warning for theme and plugin developers. + * + * The use of experimental developer APIs is intended for use by WordPress Core + * and the Gutenberg plugin exclusively. + * + * Dangerously opting in to using these APIs is NOT RECOMMENDED. Furthermore, + * the WordPress Core philosophy to strive to maintain backward compatibility + * for third-party developers DOES NOT APPLY to experimental APIs. + * + * THE CONSENT STRING FOR OPTING IN TO THESE APIS MAY CHANGE AT ANY TIME AND + * WITHOUT NOTICE. THIS CHANGE WILL BREAK EXISTING THIRD-PARTY CODE. SUCH A + * CHANGE MAY OCCUR IN EITHER A MAJOR OR MINOR RELEASE. + */ +const requiredConsent = + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.'; + +export const __dangerousOptInToUnstableAPIsOnlyForCoreModules = ( + consent, + moduleName +) => { + if ( ! CORE_MODULES_USING_EXPERIMENTS.includes( moduleName ) ) { + throw new Error( + `You tried to opt-in to unstable APIs as module "${ moduleName }". ` + + 'This feature is only for JavaScript modules shipped with WordPress core. ' + + 'Please do not use it in plugins and themes as the unstable APIs will be removed ' + + 'without a warning. If you ignore this error and depend on unstable features, ' + + 'your product will inevitably break on one of the next WordPress releases.' + ); + } + if ( registeredExperiments.includes( moduleName ) ) { + throw new Error( + `You tried to opt-in to unstable APIs as module "${ moduleName }" which is already registered. ` + + 'This feature is only for JavaScript modules shipped with WordPress core. ' + + 'Please do not use it in plugins and themes as the unstable APIs will be removed ' + + 'without a warning. If you ignore this error and depend on unstable features, ' + + 'your product will inevitably break on one of the next WordPress releases.' + ); + } + if ( consent !== requiredConsent ) { + throw new Error( + `You tried to opt-in to unstable APIs without confirming you know the consequences. ` + + 'This feature is only for JavaScript modules shipped with WordPress core. ' + + 'Please do not use it in plugins and themes as the unstable APIs will removed ' + + 'without a warning. If you ignore this error and depend on unstable features, ' + + 'your product will inevitably break on the next WordPress release.' + ); + } + registeredExperiments.push( moduleName ); + + return { + lock, + unlock, + }; +}; + +/** + * Binds private data to an object. + * It does not alter the passed object in any way, only + * registers it in an internal map of private data. + * + * The private data can't be accessed by any other means + * than the `unlock` function. + * + * @example + * ```js + * const object = {}; + * const privateData = { a: 1 }; + * lock( object, privateData ); + * + * object + * // {} + * + * unlock( object ); + * // { a: 1 } + * ``` + * + * @param {Object|Function} object The object to bind the private data to. + * @param {any} privateData The private data to bind to the object. + */ +function lock( object, privateData ) { + if ( ! object ) { + throw new Error( 'Cannot lock an undefined object.' ); + } + if ( ! ( __experiment in object ) ) { + object[ __experiment ] = {}; + } + lockedData.set( object[ __experiment ], privateData ); +} + +/** + * Unlocks the private data bound to an object. + * + * It does not alter the passed object in any way, only + * returns the private data paired with it using the `lock()` + * function. + * + * @example + * ```js + * const object = {}; + * const privateData = { a: 1 }; + * lock( object, privateData ); + * + * object + * // {} + * + * unlock( object ); + * // { a: 1 } + * ``` + * + * @param {any} object The object to unlock the private data from. + * @return {any} The private data bound to the object. + */ +function unlock( object ) { + if ( ! object ) { + throw new Error( 'Cannot unlock an undefined object.' ); + } + if ( ! ( __experiment in object ) ) { + throw new Error( + 'Cannot unlock an object that was not locked before. ' + ); + } + + return lockedData.get( object[ __experiment ] ); +} + +const lockedData = new WeakMap(); + +/** + * Used by lock() and unlock() to uniquely identify the private data + * related to a containing object. + */ +const __experiment = Symbol( 'Experiment ID' ); + +// Unit tests utilities: + +/** + * Private function to allow the unit tests to allow + * a mock module to access the experimental APIs. + * + * @param {string} name The name of the module. + */ +export function allowCoreModule( name ) { + CORE_MODULES_USING_EXPERIMENTS.push( name ); +} + +/** + * Private function to allow the unit tests to set + * a custom list of allowed modules. + */ +export function resetAllowedCoreModules() { + while ( CORE_MODULES_USING_EXPERIMENTS.length ) { + CORE_MODULES_USING_EXPERIMENTS.pop(); + } +} +/** + * Private function to allow the unit tests to reset + * the list of registered experiments. + */ +export function resetRegisteredExperiments() { + while ( registeredExperiments.length ) { + registeredExperiments.pop(); + } +} diff --git a/packages/experiments/src/index.js b/packages/experiments/src/index.js index 033816e4b28365..1e36c24b5df4bd 100644 --- a/packages/experiments/src/index.js +++ b/packages/experiments/src/index.js @@ -1,85 +1 @@ -const CORE_MODULES_USING_EXPERIMENTS = [ - '@wordpress/data', - '@wordpress/block-editor', - '@wordpress/block-library', - '@wordpress/blocks', - '@wordpress/core-data', - '@wordpress/date', - '@wordpress/edit-site', - '@wordpress/edit-widgets', -]; - -const registeredExperiments = {}; -/* - * Warning for theme and plugin developers. - * - * The use of experimental developer APIs is intended for use by WordPress Core - * and the Gutenberg plugin exclusively. - * - * Dangerously opting in to using these APIs is NOT RECOMMENDED. Furthermore, - * the WordPress Core philosophy to strive to maintain backward compatibility - * for third-party developers DOES NOT APPLY to experimental APIs. - * - * THE CONSENT STRING FOR OPTING IN TO THESE APIS MAY CHANGE AT ANY TIME AND - * WITHOUT NOTICE. THIS CHANGE WILL BREAK EXISTING THIRD-PARTY CODE. SUCH A - * CHANGE MAY OCCUR IN EITHER A MAJOR OR MINOR RELEASE. - */ -const requiredConsent = - 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.'; - -export const __dangerousOptInToUnstableAPIsOnlyForCoreModules = ( - consent, - moduleName -) => { - if ( ! CORE_MODULES_USING_EXPERIMENTS.includes( moduleName ) ) { - throw new Error( - `You tried to opt-in to unstable APIs as a module "${ moduleName }". ` + - 'This feature is only for JavaScript modules shipped with WordPress core. ' + - 'Please do not use it in plugins and themes as the unstable APIs will be removed ' + - 'without a warning. If you ignore this error and depend on unstable features, ' + - 'your product will inevitably break on one of the next WordPress releases.' - ); - } - if ( moduleName in registeredExperiments ) { - throw new Error( - `You tried to opt-in to unstable APIs as a module "${ moduleName }" which is already registered. ` + - 'This feature is only for JavaScript modules shipped with WordPress core. ' + - 'Please do not use it in plugins and themes as the unstable APIs will be removed ' + - 'without a warning. If you ignore this error and depend on unstable features, ' + - 'your product will inevitably break on one of the next WordPress releases.' - ); - } - if ( consent !== requiredConsent ) { - throw new Error( - `You tried to opt-in to unstable APIs without confirming you know the consequences. ` + - 'This feature is only for JavaScript modules shipped with WordPress core. ' + - 'Please do not use it in plugins and themes as the unstable APIs will removed ' + - 'without a warning. If you ignore this error and depend on unstable features, ' + - 'your product will inevitably break on the next WordPress release.' - ); - } - registeredExperiments[ moduleName ] = { - accessKey: {}, - apis: {}, - }; - return { - register: ( experiments ) => { - for ( const key in experiments ) { - registeredExperiments[ moduleName ].apis[ key ] = - experiments[ key ]; - } - return registeredExperiments[ moduleName ].accessKey; - }, - unlock: ( accessKey ) => { - for ( const experiment of Object.values( registeredExperiments ) ) { - if ( experiment.accessKey === accessKey ) { - return experiment.apis; - } - } - - throw new Error( - 'There is no registered module matching the specified access key' - ); - }, - }; -}; +export { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from './implementation'; diff --git a/packages/experiments/src/test/index.js b/packages/experiments/src/test/index.js index a3424ac26f7e20..2456d5ca5039d9 100644 --- a/packages/experiments/src/test/index.js +++ b/packages/experiments/src/test/index.js @@ -2,6 +2,18 @@ * Internal dependencies */ import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '../'; +import { + resetRegisteredExperiments, + resetAllowedCoreModules, + allowCoreModule, +} from '../implementation'; + +beforeEach( () => { + resetRegisteredExperiments(); + resetAllowedCoreModules(); + allowCoreModule( '@experiments/test' ); + allowCoreModule( '@experiments/test-consumer' ); +} ); const requiredConsent = 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.'; @@ -11,7 +23,7 @@ describe( '__dangerousOptInToUnstableAPIsOnlyForCoreModules', () => { expect( () => { __dangerousOptInToUnstableAPIsOnlyForCoreModules( '', - '@wordpress/data' + '@experiments/test' ); } ).toThrow( /without confirming you know the consequences/ ); } ); @@ -29,56 +41,271 @@ describe( '__dangerousOptInToUnstableAPIsOnlyForCoreModules', () => { expect( () => { __dangerousOptInToUnstableAPIsOnlyForCoreModules( requiredConsent, - '@wordpress/edit-widgets' + '@experiments/test' ); __dangerousOptInToUnstableAPIsOnlyForCoreModules( requiredConsent, - '@wordpress/edit-widgets' + '@experiments/test' ); } ).toThrow( /is already registered/ ); } ); it( 'Should grant access to unstable APIs when passed both a consent string and a previously unregistered package name', () => { const unstableAPIs = __dangerousOptInToUnstableAPIsOnlyForCoreModules( requiredConsent, - '@wordpress/edit-site' + '@experiments/test' ); + expect( unstableAPIs.lock ).toEqual( expect.any( Function ) ); expect( unstableAPIs.unlock ).toEqual( expect.any( Function ) ); - expect( unstableAPIs.register ).toEqual( expect.any( Function ) ); } ); - it( 'Should register and unlock experimental APIs', () => { - // This would live in @wordpress/data: - // Opt-in to experimental APIs - const dataExperiments = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - requiredConsent, - '@wordpress/data' - ); +} ); - // Register the experimental APIs - const dataExperimentalFunctions = { - __experimentalFunction: jest.fn(), - }; - const dataAccessKey = dataExperiments.register( - dataExperimentalFunctions +describe( 'lock(), unlock()', () => { + let lock, unlock; + beforeEach( () => { + // This would live in @experiments/test: + // Opt-in to experimental APIs + const experimentsAPI = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + '@experiments/test' ); + lock = experimentsAPI.lock; + unlock = experimentsAPI.unlock; + } ); + + it( 'Should lock and unlock objects "inside" other objects', () => { + const object = {}; + const privateData = { secret: 'sh' }; + lock( object, privateData ); + expect( unlock( object ).secret ).toBe( 'sh' ); + } ); + + it( 'Should lock and unlock functions "inside" objects', () => { + const object = {}; + const privateData = () => 'sh'; + lock( object, privateData ); + expect( unlock( object )() ).toBe( 'sh' ); + } ); + + it( 'Should lock and unlock strings "inside" objects', () => { + const object = {}; + const privateData = 'sh'; + lock( object, privateData ); + expect( unlock( object ) ).toBe( 'sh' ); + } ); + + it( 'Should lock and unlock objects "inside" functions', () => { + const fn = function () {}; + const privateData = { secret: 'sh' }; + lock( fn, privateData ); + expect( unlock( fn ).secret ).toBe( 'sh' ); + } ); + + it( 'Should lock and unlock functions "inside" other functions', () => { + const fn = function () {}; + const privateData = () => 'sh'; + lock( fn, privateData ); + expect( unlock( fn )() ).toBe( 'sh' ); + } ); + + it( 'Should lock and unlock strings "inside" functions', () => { + const fn = function () {}; + const privateData = 'sh'; + lock( fn, privateData ); + expect( unlock( fn ) ).toBe( 'sh' ); + } ); + + it( 'Should grant other opt-int modules access to locked objects', () => { + const object = {}; + const privateData = { secret: 'sh' }; + lock( object, privateData ); // This would live in @wordpress/core-data: // Register the experimental APIs const coreDataExperiments = __dangerousOptInToUnstableAPIsOnlyForCoreModules( requiredConsent, - '@wordpress/core-data' + '@experiments/test-consumer' ); - // Get the experimental APIs registered by @wordpress/data - const { __experimentalFunction } = - coreDataExperiments.unlock( dataAccessKey ); + // Get the experimental APIs registered by @experiments/test + expect( coreDataExperiments.unlock( object ).secret ).toBe( 'sh' ); + } ); +} ); - // Call one! - __experimentalFunction(); +describe( 'Specific use-cases of sharing private APIs', () => { + let lock, unlock; + beforeEach( () => { + // This would live in @experiments/test: + // Opt-in to experimental APIs + const experimentsAPI = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + '@experiments/test' + ); + lock = experimentsAPI.lock; + unlock = experimentsAPI.unlock; + } ); + + it( 'Should enable privately exporting experimental functions', () => { + /** + * Problem: The private __experimentalFunction should not be publicly + * exposed to the consumers of package1. + */ + function __experimentalFunction() {} + /** + * Solution: Privately lock it inside a publicly exported object. + * + * In `package1/index.js` we'd say: + * + * ```js + * export const experiments = {}; + * lock(experiments, { + * __experimentalFunction + * }); + * ``` + * + * Let's simulate in the test code: + */ + const experiments = {}; + const package1Exports = { + experiments, + }; + lock( experiments, { __experimentalFunction } ); + + /** + * Then, in the consumer package we'd say: + * + * ```js + * import { experiments } from 'package1'; + * const { __experimentalFunction } = unlock( experiments ); + * ``` + * + * Let's simulate that, too: + */ + const unlockedFunction = unlock( + package1Exports.experiments + ).__experimentalFunction; + expect( unlockedFunction ).toBe( __experimentalFunction ); + } ); - expect( - dataExperimentalFunctions.__experimentalFunction - ).toHaveBeenCalled(); + it( 'Should enable exporting functions with private experimental arguments', () => { + /** + * The publicly exported function does not have any experimental + * arguments. + * + * @param {any} data The data to log + */ + function logData( data ) { + // Internally, it calls the experimental version of the function + // with fixed default values for the experimental arguments. + __experimentalLogData( data, 'plain' ); + } + /** + * The private experimental function is not publicly exported. Instead, it's + * "locked" inside of the public logData function. It can be unlocked by any + * participant of the private importing system. + * + * @param {any} data The data to log + * @param {string} __experimentalFormat The logging format to use. + */ + function __experimentalLogData( data, __experimentalFormat ) { + if ( __experimentalFormat === 'table' ) { + // eslint-disable-next-line no-console + console.table( data ); + } else { + // eslint-disable-next-line no-console + console.log( data ); + } + } + lock( logData, __experimentalLogData ); + /** + * In package/log-data.js: + * + * ```js + * lock( logData, __experimentalLogData ); + * export logData; + * ``` + * + * Then, in package/index.js: + * + * ```js + * export { logData } from './log-data'; + * ``` + * + * And that's it! The public function is publicly exported, and the + * experimental function is available via unlock( logData ): + * + * ```js + * import { logData } from 'package1'; + * const experimenalLogData = unlock( logData ); + * ``` + */ + expect( unlock( logData ) ).toBe( __experimentalLogData ); + } ); + + it( 'Should enable exporting React Components with private experimental properties', () => { + // eslint-disable-next-line jsdoc/require-param + /** + * The publicly exported component does not have any experimental + * properties. + */ + function DataTable( { data } ) { + // Internally, it calls the experimental version of the function + // with fixed default values for the experimental arguments. + return ( + + ); + } + // eslint-disable-next-line jsdoc/require-param + /** + * The private experimental component is not publicly exported. Instead, it's + * "locked" inside of the public logData function. It can be unlocked by any + * participant of the private importing system. + */ + function ExperimentalDataTable( { + data, + __experimentalFancyFormatting, + } ) { + const className = __experimentalFancyFormatting + ? 'savage-css' + : 'regular-css'; + return ( + + { data.map( ( row, i ) => ( + + { row.map( ( col, j ) => ( + + ) ) } + + ) ) } +
{ col }
+ ); + } + lock( DataTable, ExperimentalDataTable ); + /** + * In package/data-table.js: + * + * ```js + * lock( DataTable, ExperimentalDataTable ); + * export DataTable; + * ``` + * + * Then, in package/index.js: + * + * ```js + * export { DataTable } from './data-table'; + * ``` + * + * And that's it! The public function is publicly exported, and the + * experimental function is available via unlock( logData ): + * + * ```js + * import { DataTable } from 'package1'; + * const ExperimentalDataTable = unlock( DataTable ); + * ``` + */ + expect( unlock( DataTable ) ).toBe( ExperimentalDataTable ); } ); } );