From ace58e416c020c9775f947ae9fe788cd73a1694e Mon Sep 17 00:00:00 2001 From: Adam Zielinski Date: Mon, 26 Sep 2022 16:36:56 +1000 Subject: [PATCH] Limit access to experimental APIs to WordPress codebase (#43386) Make the __experimental APIs private by introducing a dealer mechanism that only grants access to core WordPress packages. It solves the problem of leaking private experimental APIs to extenders in public stable releases. See https://github.com/WordPress/gutenberg/issues/40316 for more details. Usage example: ```js // in @wordpress/data import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; const experiments = __dangerousOptInToUnstableAPIsOnlyForCoreModules( 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', '@wordpress/data' ); export const __experiments = experiments.register({ __experimentalFunction: () => { /* ... */ }, }); ``` ```js // In @wordpress/core-data: import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/experiments'; import { __experiments as __dataExperiments } from '@wordpress/data'; const experiments = __dangerousOptInToUnstableAPIsOnlyForCoreModules( 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.', '@wordpress/core-data' ); // Get the experimental APIs registered by @wordpress/data const { __experimentalFunction } = experiments.unlock( __dataExperiments ); __experimentalFunction(); ``` --- docs/manifest.json | 6 ++ package-lock.json | 6 ++ package.json | 1 + packages/experiments/package.json | 35 +++++++++++ packages/experiments/src/index.js | 85 ++++++++++++++++++++++++++ packages/experiments/src/test/index.js | 84 +++++++++++++++++++++++++ 6 files changed, 217 insertions(+) create mode 100644 packages/experiments/package.json create mode 100644 packages/experiments/src/index.js create mode 100644 packages/experiments/src/test/index.js diff --git a/docs/manifest.json b/docs/manifest.json index 69d991c65d3da5..b3cabe1e3a7e8c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1607,6 +1607,12 @@ "markdown_source": "../packages/eslint-plugin/README.md", "parent": "packages" }, + { + "title": "@wordpress/experiments", + "slug": "packages-experiments", + "markdown_source": "../packages/experiments/README.md", + "parent": "packages" + }, { "title": "@wordpress/format-library", "slug": "packages-format-library", diff --git a/package-lock.json b/package-lock.json index 69f456723c5f08..c6a9f64478232e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17453,6 +17453,12 @@ "requireindex": "^1.2.0" } }, + "@wordpress/experiments": { + "version": "file:packages/experiments", + "requires": { + "@babel/runtime": "^7.16.0" + } + }, "@wordpress/format-library": { "version": "file:packages/format-library", "requires": { diff --git a/package.json b/package.json index a01d677fb5aadb..b72797c8fc43c8 100755 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@wordpress/editor": "file:packages/editor", "@wordpress/element": "file:packages/element", "@wordpress/escape-html": "file:packages/escape-html", + "@wordpress/experiments": "file:packages/experiments", "@wordpress/format-library": "file:packages/format-library", "@wordpress/hooks": "file:packages/hooks", "@wordpress/html-entities": "file:packages/html-entities", diff --git a/packages/experiments/package.json b/packages/experiments/package.json new file mode 100644 index 00000000000000..85310d82565324 --- /dev/null +++ b/packages/experiments/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/dependency-injection", + "version": "0.0.1", + "description": "Dependency Injection container for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "dom", + "utils" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/dependency-injection/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/injection" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/experiments/src/index.js b/packages/experiments/src/index.js new file mode 100644 index 00000000000000..033816e4b28365 --- /dev/null +++ b/packages/experiments/src/index.js @@ -0,0 +1,85 @@ +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' + ); + }, + }; +}; diff --git a/packages/experiments/src/test/index.js b/packages/experiments/src/test/index.js new file mode 100644 index 00000000000000..a3424ac26f7e20 --- /dev/null +++ b/packages/experiments/src/test/index.js @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '../'; + +const requiredConsent = + 'I know using unstable features means my plugin or theme will inevitably break on the next WordPress release.'; + +describe( '__dangerousOptInToUnstableAPIsOnlyForCoreModules', () => { + it( 'Should require a consent string', () => { + expect( () => { + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + '', + '@wordpress/data' + ); + } ).toThrow( /without confirming you know the consequences/ ); + } ); + it( 'Should require a valid @wordpress package name', () => { + expect( () => { + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + 'custom_package' + ); + } ).toThrow( + /This feature is only for JavaScript modules shipped with WordPress core/ + ); + } ); + it( 'Should not register the same module twice', () => { + expect( () => { + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + '@wordpress/edit-widgets' + ); + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + '@wordpress/edit-widgets' + ); + } ).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' + ); + 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 + ); + + // This would live in @wordpress/core-data: + // Register the experimental APIs + const coreDataExperiments = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + requiredConsent, + '@wordpress/core-data' + ); + + // Get the experimental APIs registered by @wordpress/data + const { __experimentalFunction } = + coreDataExperiments.unlock( dataAccessKey ); + + // Call one! + __experimentalFunction(); + + expect( + dataExperimentalFunctions.__experimentalFunction + ).toHaveBeenCalled(); + } ); +} );