Skip to content

Commit

Permalink
Experiments: sharing private APIs with lock() and unlock() (#46131)
Browse files Browse the repository at this point in the history
## 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(
	'<CONSENT STRING>',
	'@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](#44521 (comment))!

## 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 <div/>; },
	__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 (
		<button>
			{ __experimentalShowIcon  && <Icon src={some icon} /> } { title }  
		</button>
	);
}

// The stable public component is a thin wrapper that calls the
// experimental component with the experimental features disabled
export const MyButton = ( { title } ) => 
    <ExperimentalMyExistingButton title={ title } __experimentalShowIcon={ false } />

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 (
		<ExperimentalMyButton data={data} __experimentalShowIcon={ true } />
	)
}
```


Co-authored-by: Ramon <[email protected]>
Co-authored-by: Robert Anderson <[email protected]>
  • Loading branch information
3 people authored Jan 17, 2023
1 parent 47384e0 commit 1b18104
Show file tree
Hide file tree
Showing 10 changed files with 1,113 additions and 151 deletions.
310 changes: 309 additions & 1 deletion docs/contributors/code/coding-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div/>; },
__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 (
<button>
{ __experimentalShowIcon && <Icon src={some icon} /> } { title }
</button>
);
}

// The stable public component is a thin wrapper that calls the
// experimental component with the experimental features disabled
export const MyButton = ( { title } ) =>
<ExperimentalMyButton title={ title } __experimentalShowIcon={ false } />

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 (
<ExperimentalMyButton data={data} __experimentalShowIcon={ true } />
)
}
```

#### 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:
Expand Down
Loading

0 comments on commit 1b18104

Please sign in to comment.