Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiments: sharing private APIs with lock() and unlock() #46131

Merged
merged 23 commits into from
Jan 17, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
982b862
Experiments: pivot towards lock()/unlock() based API
adamziel Nov 28, 2022
bb2e1e0
Rename lazyDecorator to onFirstUnlock and use it to process even the …
adamziel Nov 28, 2022
48fd5a4
Document the new API
adamziel Nov 28, 2022
65750cf
Add unit tests to demonstrate how to export private functions, functi…
adamziel Nov 30, 2022
12bbcb6
Explain how to avoid new experimental APIs in coding-guidelines.md
adamziel Dec 19, 2022
2a706c3
Editorial updates to the documentation
adamziel Dec 21, 2022
3efd500
Editorial updates to coding guidelines
adamziel Dec 21, 2022
435ea50
Do not export isExperimentsConfig from the experiments package
adamziel Dec 22, 2022
75ec4fd
Clarify the deprecation process does not always span multiple WP
adamziel Dec 22, 2022
7f1b499
Private experimental cross-module selectors and actions (#44521)
adamziel Dec 22, 2022
6933b2a
Roll back the demo changes to blocks/ and block-editor/ packages
adamziel Dec 22, 2022
f8e199f
Update the coding guidelines to reflect registerPrivateActionsAndSele…
adamziel Dec 22, 2022
cb11084
Lint
adamziel Dec 22, 2022
8b5103c
Use consistent method names in docstring examples
adamziel Dec 22, 2022
00a07b8
Rebuild docs
adamziel Dec 22, 2022
ce9d2a2
Remove the configureLockTarget utility
adamziel Dec 22, 2022
9a01309
Update packages/experiments/README.md
adamziel Dec 23, 2022
22369b7
Lint
adamziel Jan 16, 2023
eaff546
Fix typo in coding-guidelines.md
adamziel Jan 16, 2023
41582b7
Update packages/experiments/src/implementation.js
adamziel Jan 17, 2023
c0ebbf8
Update packages/experiments/src/implementation.js
adamziel Jan 17, 2023
583fd45
Clarify the meaning of the second argument to __dangerousOptInToUnsta…
adamziel Jan 17, 2023
954b353
Assert that the public selector and actions still exist on the object…
adamziel Jan 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 308 additions & 1 deletion docs/contributors/code/coding-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,317 @@ 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' // The package opting in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a long long time to realise that this is supposed to be the name of the package calling __dangerousOptInToUnstableAPIsOnlyForCoreModules, not the name of the package whose APIs you want to access.

I wonder if there's a way to get the package name from an environment variable or something so that it's clearer.

Probably not. Just noting my confusion nonetheless.

Copy link
Contributor Author

@adamziel adamziel Jan 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good note! I just updated the docs to clarify that.

As for the automation – both arguments could probably be injected by a custom babel plugin. Brownie points – it would obscure the correct usage even further for extenders trying to reverse-engineer it without consulting the docs. Would you be game for exploring it?

);
```

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()
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
// 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
youknowriad marked this conversation as resolved.
Show resolved Hide resolved

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