diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index a4f1e5fc0..dedd0661f 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -58,6 +58,11 @@ steps: command: make lint-js plugins: *plugins + - label: ':npm: Check @wordpress package versions' + key: check-wp-packages + command: make check-wp-packages + plugins: *plugins + - label: ':javascript: Test JavaScript' key: test-js command: make test-js @@ -176,6 +181,7 @@ steps: - swift-test-swift-package - lint-js - lint-swift + - check-wp-packages - test-js - test-web-e2e - test-ios-e2e diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 00bed05ff..a7011a60c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,21 @@ updates: directory: '/' schedule: interval: 'weekly' + groups: + # The editor exposes @wordpress packages on window.wp as single + # instances, so they must move together like a Gutenberg release. + # Bumping them one at a time leaves mismatched ranges that nest + # duplicate copies. + # + # Scoped to production dependencies, matching `make + # check-wp-packages`: dev tooling (eslint-plugin, env, + # prettier-config, dependency-extraction-webpack-plugin) never + # reaches the bundle, and because Dependabot ships a group + # atomically, a breaking tooling major would hold back every + # runtime bump alongside it. + wordpress-packages: + patterns: ['@wordpress/*'] + dependency-type: 'production' ignore: - dependency-name: 'eslint' # eslint@>=9.x.x blocked by https://github.com/WordPress/gutenberg/issues/64782 update-types: ['version-update:semver-major'] diff --git a/Makefile b/Makefile index ee1aef407..79b5475b9 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,13 @@ format: npm-dependencies ## Format code lint-js: npm-dependencies ## Lint JavaScript code npm run lint:js +# Reads `package-lock.json`, not the installed tree, so it needs no +# `npm-dependencies` prerequisite -- which would otherwise report on a stale +# `node_modules` whenever one already exists. +.PHONY: check-wp-packages +check-wp-packages: ## Fail if any @wordpress package is installed more than once + npm run check:wp-packages + .PHONY: lint-fix-js lint-js-fix: npm-dependencies ## Lint and auto-fix JavaScript code npm run lint:js:fix diff --git a/bin/README.md b/bin/README.md index b760dbc00..7044873da 100644 --- a/bin/README.md +++ b/bin/README.md @@ -88,3 +88,19 @@ Prepares translations for the GutenbergKit project. This script is typically run ```bash make prep-translations ``` + +### `check-wordpress-package-duplicates.js` + +Fails when any `@wordpress` package is installed more than once in the production dependency graph. The editor exposes these packages on `window.wp` for plugin scripts, which only works when each package is a single module instance. A nested second copy brings its own React contexts, data stores, and private APIs, and nothing at runtime reports the split. + +The check reads `package-lock.json` rather than the installed tree, so it describes what a fresh `npm ci` produces, requires no `node_modules`, and counts separate installs rather than distinct versions — two copies of the same version are still two module instances. + +Packages listed in the script's `KNOWN_DUPLICATES` are reported as warnings instead of failures. Each entry should be removed once the underlying version mismatch is resolved, typically by bumping the `@wordpress` packages together; an entry that no longer applies fails the check rather than lingering to mask a future regression. + +An entry is a deliberate exception rather than a fix, and is warranted when the coordinated bump genuinely has to wait — a security advisory that moves one package alone, for instance. The cost is that the editor ships a known split: for a package holding a store, context, or registry, plugin scripts receive a different instance than the editor's own components, with nothing at runtime to signal it. Record the follow-up work alongside the entry. + +#### Usage + +```bash +make check-wp-packages +``` diff --git a/bin/check-wordpress-package-duplicates.js b/bin/check-wordpress-package-duplicates.js new file mode 100644 index 000000000..38d3385db --- /dev/null +++ b/bin/check-wordpress-package-duplicates.js @@ -0,0 +1,217 @@ +#!/usr/bin/env node + +/** + * Fail when any @wordpress package is installed more than once in the + * production dependency graph. + * + * The editor exposes its @wordpress packages on `window.wp` for plugin + * scripts, mirroring WP Admin. That only works when each package is a single + * module instance: a second copy nested under another dependency brings its + * own React contexts, data stores, and private APIs, and nothing at runtime + * reports the split. `wordPressExternals()` in `vite.config.js` rewrites + * source imports to `window.wp` but deliberately skips `node_modules`, so a + * nested copy is bundled by path and does become a second instance. + * + * The lockfile is the source of truth rather than the installed tree: it + * describes what a fresh `npm ci` produces, needs no `node_modules`, and its + * install paths distinguish separate copies of the same version, which are + * still separate module instances. + */ + +/** + * External dependencies + */ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Internal dependencies + */ +import { error, info, warn } from '../src/utils/logger.js'; + +const rootDir = join( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const lockfilePath = join( rootDir, 'package-lock.json' ); + +const NODE_MODULES = 'node_modules/'; + +/** + * Packages known to be installed more than once. Each entry should reference + * the work that will remove it, and is reported as a warning so the debt stays + * visible. An entry that no longer applies fails the check rather than + * lingering to mask a future regression. + */ +const KNOWN_DUPLICATES = { + // Dependabot bumped commands, components, and preferences past the + // icons range required by block-editor and editor. Resolves once the + // @wordpress packages are bumped together to a single release. + '@wordpress/icons': true, +}; + +process.exitCode = checkForDuplicateInstalls(); + +/** + * Report every @wordpress package the lockfile installs more than once, along + * with any `KNOWN_DUPLICATES` entry that no longer applies. + * + * @return {number} Process exit code. + */ +function checkForDuplicateInstalls() { + const lockfile = readLockfile(); + + if ( ! lockfile ) { + return 1; + } + + const installsByPackage = collectWordPressPackageInstalls( lockfile ); + + // A tree with no @wordpress packages means the lockfile is empty or in an + // unexpected format. Passing here would make a check that examined nothing + // indistinguishable from one that found nothing wrong. + if ( installsByPackage.size === 0 ) { + error( + `No @wordpress packages found in ${ lockfilePath }. The lockfile is empty or in an unexpected format, so nothing was checked.` + ); + return 1; + } + + const staleAllowances = new Set( + Object.keys( KNOWN_DUPLICATES ).filter( isKnownDuplicate ) + ); + let hasUnexpectedDuplicate = false; + + for ( const [ name, installs ] of installsByPackage ) { + if ( installs.length < 2 ) { + continue; + } + + const message = `${ name } is installed ${ + installs.length + } times: ${ describeInstalls( installs ) }`; + + if ( isKnownDuplicate( name ) ) { + staleAllowances.delete( name ); + warn( `Known duplicate: ${ message }` ); + } else { + hasUnexpectedDuplicate = true; + error( message ); + } + } + + if ( hasUnexpectedDuplicate ) { + error( + 'Every @wordpress package must have a single install so window.wp exposes one instance. Bump the lagging package, or bump all @wordpress packages together.' + ); + error( + "When that has to wait -- a security advisory moving one package alone, say -- add it to this script's KNOWN_DUPLICATES with a link to the follow-up. That is a deliberate exception rather than a fix: the editor then ships a known split, and for a package holding a store, context, or registry, plugin scripts get a different instance than the editor's own components with nothing at runtime to signal it." + ); + } + + for ( const name of staleAllowances ) { + error( + `${ name } is listed in KNOWN_DUPLICATES but is no longer duplicated. Remove the entry so it cannot mask a future regression.` + ); + } + + if ( hasUnexpectedDuplicate || staleAllowances.size > 0 ) { + return 1; + } + + info( + `Checked ${ installsByPackage.size } @wordpress packages; no unexpected duplicates.` + ); + + return 0; +} + +/** + * Read and parse the lockfile. + * + * @return {Object|undefined} Parsed lockfile, or undefined when it cannot be + * read or parsed. + */ +function readLockfile() { + let contents; + + try { + contents = readFileSync( lockfilePath, 'utf8' ); + } catch ( readError ) { + error( `Could not read ${ lockfilePath }: ${ readError.message }` ); + return undefined; + } + + try { + return JSON.parse( contents ); + } catch ( parseError ) { + error( `Could not parse ${ lockfilePath }: ${ parseError.message }` ); + return undefined; + } +} + +/** + * Map each @wordpress package to every place the lockfile installs it. + * + * The `packages` keys are install paths, so a nested copy is a distinct entry + * even when it shares a version with the hoisted one. Development-only entries + * are skipped because they never reach the bundle. + * + * @param {Object} lockfile Parsed `package-lock.json`. + * @return {Map>} Package name to + * its installs. + */ +function collectWordPressPackageInstalls( lockfile ) { + const installs = new Map(); + + for ( const [ path, entry ] of Object.entries( lockfile.packages || {} ) ) { + if ( entry.dev || ! entry.version ) { + continue; + } + + const index = path.lastIndexOf( NODE_MODULES ); + + if ( index === -1 ) { + continue; + } + + const name = path.slice( index + NODE_MODULES.length ); + + if ( ! name.startsWith( '@wordpress/' ) ) { + continue; + } + + if ( ! installs.has( name ) ) { + installs.set( name, [] ); + } + + installs.get( name ).push( { path, version: entry.version } ); + } + + return installs; +} + +/** + * Whether a package is currently allowed to be installed more than once. + * + * Membership alone is not enough: an entry set to a falsy value is treated as + * removed, so that toggling one off reports the duplicate rather than claiming + * the allowance went stale. + * + * @param {string} name Package name. + * @return {boolean} True when the duplicate is allowed. + */ +function isKnownDuplicate( name ) { + return Boolean( KNOWN_DUPLICATES[ name ] ); +} + +/** + * Format installs as `version (path)` pairs so the nesting parent is visible. + * + * @param {Array<{path: string, version: string}>} installs Installs of a single + * package. + * @return {string} Human readable list of installs. + */ +function describeInstalls( installs ) { + return installs + .map( ( { path, version } ) => `${ version } (${ path })` ) + .join( ', ' ); +} diff --git a/docs/code/plugins.md b/docs/code/plugins.md index cc3c5c404..e06705756 100644 --- a/docs/code/plugins.md +++ b/docs/code/plugins.md @@ -17,6 +17,17 @@ This approach provides: - **Extensibility**: Supports custom blocks and plugins when connected - **Performance**: Core packages load instantly from local bundle +### One instance per `@wordpress` package + +Plugin scripts reach the bundled packages through `window.wp`, populated by `src/utils/wordpress-globals.js`, in the same way WP Admin exposes them. Every exposed package is declared as a direct dependency so the version is explicit, linted, and tracked by Dependabot. + +Each package must have a single install in the dependency graph. A second copy nested under another dependency brings its own React contexts, data stores, and private APIs, so plugins would receive a different instance than the editor's own components use, with no error to signal the split. Two safeguards keep this from happening: + +- Dependabot groups `@wordpress/*` updates so they move together, as a Gutenberg release does. +- `make check-wp-packages` reads `package-lock.json` and fails when any `@wordpress` package is installed more than once, and runs in CI. + +Duplicates the project has accepted for the time being are listed in the check's `KNOWN_DUPLICATES` and reported as warnings, so the invariant is enforced going forward rather than satisfied today. `@wordpress/icons` is currently allowed, pending a coordinated bump of the `@wordpress` packages. + ## Configuration Enable plugins by setting the `plugins` configuration option. The editor will fetch assets from the configured `editorAssetsEndpoint` or fall back to the default Jetpack endpoint. The demo app UI allows adding site-specific editor configurations, which enables the `plugins` configuration option. diff --git a/package.json b/package.json index 2c05718c2..103f23f5c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:force": "vite --host --force", "dev:tools": "react-devtools", "build": "vite --emptyOutDir build", + "check:wp-packages": "node bin/check-wordpress-package-duplicates.js", "format": "prettier --write .", "generate-version": "node bin/generate-version.js", "lint:js": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",