Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +181,7 @@ steps:
- swift-test-swift-package
- lint-js
- lint-swift
- check-wp-packages
- test-js
- test-web-e2e
- test-ios-e2e
Expand Down
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
217 changes: 217 additions & 0 deletions bin/check-wordpress-package-duplicates.js

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've never implemented or seen a check list this, but duplicates can cause cryptic failures so avoiding them is valuable. If this check proves problematic in the future, we can remove it.

Original file line number Diff line number Diff line change
@@ -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<string, Array<{path: string, version: string}>>} 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( ', ' );
}
11 changes: 11 additions & 0 deletions docs/code/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading