From a89aab7296f761a03876677e271394a42dac1a80 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 20:10:51 -0300 Subject: [PATCH 01/40] create wp-build-polyfills package --- .../packages/wp-build-polyfills/.gitignore | 3 + .../bin/build-polyfills.mjs | 424 ++++++++++++++++++ .../wp-build-polyfills/changelog/.gitkeep | 0 .../packages/wp-build-polyfills/composer.json | 39 ++ .../packages/wp-build-polyfills/package.json | 29 ++ .../src/class-wp-build-polyfills.php | 134 ++++++ 6 files changed, 629 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/.gitignore create mode 100755 projects/packages/wp-build-polyfills/bin/build-polyfills.mjs create mode 100644 projects/packages/wp-build-polyfills/changelog/.gitkeep create mode 100644 projects/packages/wp-build-polyfills/composer.json create mode 100644 projects/packages/wp-build-polyfills/package.json create mode 100644 projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php diff --git a/projects/packages/wp-build-polyfills/.gitignore b/projects/packages/wp-build-polyfills/.gitignore new file mode 100644 index 000000000000..65f08b37e4fe --- /dev/null +++ b/projects/packages/wp-build-polyfills/.gitignore @@ -0,0 +1,3 @@ +.cache/ +/node_modules +build/ diff --git a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs new file mode 100755 index 000000000000..17baad90405b --- /dev/null +++ b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs @@ -0,0 +1,424 @@ +#!/usr/bin/env node + +/** + * Build script for Core package polyfills. + * + * Bundles `@wordpress` packages that are not available in WordPress Core < 7.0 + * (private-apis, theme, boot, route, a11y) so that plugins using + * wp-build can conditionally register them when Core/Gutenberg doesn't provide them. + * + * Uses the same externals strategy as wp-build's wordpress-externals-plugin: + * - Classic scripts (IIFE): `@wordpress/*` → window.wp.{camelCase}, vendor → globals + * - Script modules (ESM): `@wordpress/*` script modules → external (import map), + * `@wordpress/*` classic-only → window.wp.{camelCase}, vendor → globals + */ + +import { createHash } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseArgs } from 'util'; +import { build } from 'esbuild'; + +// Resolve packages from this package's own node_modules, not the consumer's. +const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); +const packageRoot = path.resolve( __dirname, '..' ); +const require = createRequire( path.join( packageRoot, 'package.json' ) ); + +// Parse CLI arguments. +const { values: args } = parseArgs( { + options: { + 'output-dir': { type: 'string', default: 'build/polyfills' }, + }, + strict: false, +} ); + +const outputBase = path.resolve( args[ 'output-dir' ] ); + +// ── Vendor externals (same as wp-build) ────────────────────────────────────── + +const vendorExternals = { + react: { global: 'React', handle: 'react' }, + 'react-dom': { global: 'ReactDOM', handle: 'react-dom' }, + 'react/jsx-runtime': { + global: 'ReactJSXRuntime', + handle: 'react-jsx-runtime', + }, + 'react/jsx-dev-runtime': { + global: 'ReactJSXRuntime', + handle: 'react-jsx-runtime', + }, + moment: { global: 'moment', handle: 'moment' }, + lodash: { global: 'lodash', handle: 'lodash' }, + 'lodash-es': { global: 'lodash', handle: 'lodash' }, + jquery: { global: 'jQuery', handle: 'jquery' }, +}; + +// ── Package info cache ─────────────────────────────────────────────────────── + +const packageJsonCache = new Map(); + +/** + * Get the package JSON for a package. + * @param {string} packageName - The package name. + * @param {string} resolveDir - The directory to resolve the package from. + * @return {object} The package JSON. + */ +function getPackageJson( packageName, resolveDir = null ) { + const contextDir = resolveDir || packageRoot; + const cacheKey = `${ packageName }@${ contextDir }`; + + if ( packageJsonCache.has( cacheKey ) ) { + return packageJsonCache.get( cacheKey ); + } + + try { + const contextRequire = createRequire( path.join( contextDir, 'package.json' ) ); + + const pkgPath = contextRequire.resolve( `${ packageName }/package.json` ); + + const pkg = JSON.parse( readFileSync( pkgPath, 'utf8' ) ); + packageJsonCache.set( cacheKey, pkg ); + + return pkg; + } catch { + packageJsonCache.set( cacheKey, null ); + + return null; + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Convert a string to camel case. + * + * @param {string} str - The string to convert. + * @return {string} The camel case string. + */ +function camelCase( str ) { + return str.replace( /-([a-z])/g, ( _, c ) => c.toUpperCase() ); +} + +/** + * Check if a subpath is a script module import. + * @param {object} packageJson - The package JSON object. + * @param {string} subpath - The subpath to check. + * @return {boolean} Whether the subpath is a script module import. + */ +function isScriptModuleImport( packageJson, subpath ) { + const { wpScriptModuleExports } = packageJson; + + if ( ! wpScriptModuleExports ) { + return false; + } + + if ( ! subpath ) { + if ( typeof wpScriptModuleExports === 'string' ) { + return true; + } + + if ( typeof wpScriptModuleExports === 'object' && wpScriptModuleExports[ '.' ] ) { + return true; + } + + return false; + } + + if ( typeof wpScriptModuleExports === 'object' && wpScriptModuleExports[ `./${ subpath }` ] ) { + return true; + } + + return false; +} + +/** + * Generate a SHA-256 hash of the content. + * @param {string} content - The content to hash. + * @return {string} The SHA-256 hash. + */ +function generateContentHash( content ) { + return createHash( 'sha256' ).update( content ).digest( 'hex' ).slice( 0, 20 ); +} + +// ── Externals plugin ───────────────────────────────────────────────────────── + +/** + * Create an externals plugin for the polyfills. + * @param {string} buildFormat - The build format. + * @param {string} skipPackage - The package to skip. + * @return {object} The externals plugin. + */ +function polyfillExternalsPlugin( buildFormat, skipPackage = null ) { + const dependencies = new Set(); + const moduleDependencies = new Map(); + + return { + name: 'polyfill-externals', + setup( esb ) { + // Vendor externals + for ( const [ packageName, config ] of Object.entries( vendorExternals ) ) { + esb.onResolve( { filter: new RegExp( `^${ packageName }$` ) }, onResolveArgs => { + dependencies.add( config.handle ); + + return { + path: onResolveArgs.path, + namespace: 'vendor-external', + pluginData: { global: config.global }, + }; + } ); + } + + // @wordpress/* externals + esb.onResolve( { filter: /^@wordpress\// }, onResolveArgs => { + const parts = onResolveArgs.path.split( '/' ); + const packageName = parts.slice( 0, 2 ).join( '/' ); + const subpath = parts.length > 2 ? parts.slice( 2 ).join( '/' ) : null; + const shortName = parts[ 1 ]; + const handle = `wp-${ shortName }`; + + // Don't externalize the package we're building + if ( skipPackage && packageName === skipPackage ) { + return undefined; + } + + const packageJson = getPackageJson( packageName, onResolveArgs.resolveDir ); + + if ( ! packageJson ) { + return undefined; + } + + let isScriptModule = isScriptModuleImport( packageJson, subpath ); + let isScript = !! packageJson.wpScript; + + // Dual packages: use the format being built + if ( isScriptModule && isScript ) { + isScript = buildFormat === 'iife'; + isScriptModule = buildFormat === 'esm'; + } + + const kind = onResolveArgs.kind === 'dynamic-import' ? 'dynamic' : 'static'; + + if ( isScriptModule ) { + if ( kind === 'static' ) { + moduleDependencies.set( onResolveArgs.path, 'static' ); + } else if ( ! moduleDependencies.has( onResolveArgs.path ) ) { + moduleDependencies.set( onResolveArgs.path, 'dynamic' ); + } + + return { + path: onResolveArgs.path, + external: true, + sideEffects: !! packageJson.sideEffects, + }; + } + + if ( isScript ) { + dependencies.add( handle ); + + return { + path: onResolveArgs.path, + namespace: 'package-external', + pluginData: { globalName: 'wp' }, + }; + } + + // Not a registered script or module — let esbuild bundle it + return undefined; + } ); + + esb.onLoad( { filter: /.*/, namespace: 'vendor-external' }, onLoadArgs => ( { + contents: `module.exports = window.${ onLoadArgs.pluginData.global };`, + loader: 'js', + } ) ); + + esb.onLoad( { filter: /.*/, namespace: 'package-external' }, onLoadArgs => { + const packagePath = onLoadArgs.path.split( '/' ).slice( 1 ).join( '/' ); + const name = camelCase( packagePath ); + + return { + contents: `module.exports = window.${ onLoadArgs.pluginData.globalName }.${ name };`, + loader: 'js', + }; + } ); + + // Generate asset file on build end + esb.onEnd( result => { + if ( result.errors.length > 0 ) { + return; + } + + const outfile = esb.initialOptions.outfile; + const outputDir = path.dirname( outfile ); + const baseName = path.basename( outfile, '.js' ); + + // Read the output to hash it + const outputContent = readFileSync( outfile ); + const version = generateContentHash( outputContent ); + + const sortedDeps = Array.from( dependencies ).sort(); + const depsString = sortedDeps.map( d => `'${ d }'` ).join( ', ' ); + + const assetParts = [ `'dependencies' => array(${ depsString })` ]; + + if ( moduleDependencies.size > 0 ) { + const modDeps = Array.from( moduleDependencies.entries() ) + .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) + .map( ( [ dep, impKind ] ) => `array('id' => '${ dep }', 'import' => '${ impKind }')` ) + .join( ', ' ); + + assetParts.push( `'module_dependencies' => array(${ modDeps })` ); + } + + assetParts.push( `'version' => '${ version }'` ); + + const assetContent = `=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-wp-build-polyfills", + "textdomain": "jetpack-wp-build-polyfills", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-wp-build-polyfills/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + } + } +} diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json new file mode 100644 index 000000000000..0383fd3cb5de --- /dev/null +++ b/projects/packages/wp-build-polyfills/package.json @@ -0,0 +1,29 @@ +{ + "name": "@automattic/jetpack-wp-build-polyfills", + "version": "0.1.0", + "private": true, + "description": "Polyfills for WordPress Core packages not available in WP < 7.0", + "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/wp-build-polyfills/#readme", + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/packages/wp-build-polyfills" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "bin": { + "build-polyfills": "bin/build-polyfills.mjs" + }, + "devDependencies": { + "@wordpress/a11y": "^4.40.0", + "@wordpress/boot": "^0.7.1", + "@wordpress/private-apis": "^1.40.0", + "@wordpress/route": "^0.6.0", + "@wordpress/theme": "^0.7.0", + "esbuild": "^0.27.0" + }, + "optionalDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + } +} diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php new file mode 100644 index 000000000000..720715ae208a --- /dev/null +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -0,0 +1,134 @@ + array( + 'path' => 'private-apis', + 'deps' => array(), + // Always replace: older Core versions ship private-apis with an + // incomplete allowlist that rejects @wordpress/theme and @wordpress/route. + // Our version is a strict superset (same API, larger allowlist). + 'force' => true, + ), + 'wp-theme' => array( + 'path' => 'theme', + 'deps' => array( 'wp-element', 'wp-private-apis' ), + ), + ); + + foreach ( $polyfills as $handle => $data ) { + $asset_file = $polyfills_dir . '/scripts/' . $data['path'] . '/index.min.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + continue; + } + + $force = ! empty( $data['force'] ); + + if ( ! $force && $scripts->query( $handle, 'registered' ) ) { + continue; + } + + // Deregister first when forcing replacement of an existing registration. + if ( $force && $scripts->query( $handle, 'registered' ) ) { + $scripts->remove( $handle ); + } + + $asset = require $asset_file; + + $scripts->add( + $handle, + plugins_url( 'build/polyfills/scripts/' . $data['path'] . '/index.min.js', $base_file ), + $asset['dependencies'] ?? $data['deps'], + $asset['version'] ?? false + ); + } + } + + /** + * Register polyfill script modules. + * + * Call to wp_register_script_module() silently ignores duplicate registrations (first wins), + * so no explicit is_registered check is needed. + * + * @param string $polyfills_dir Absolute path to the polyfills build directory. + * @param string $base_file File path for plugins_url() computation. + */ + private static function register_modules( $polyfills_dir, $base_file ) { + if ( ! function_exists( 'wp_register_script_module' ) ) { + return; + } + + $modules = array( 'boot', 'route', 'a11y' ); + + foreach ( $modules as $name ) { + $module_id = '@wordpress/' . $name; + $asset_file = $polyfills_dir . '/modules/' . $name . '/index.min.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + continue; + } + + $asset = require $asset_file; + + wp_register_script_module( + $module_id, + plugins_url( 'build/polyfills/modules/' . $name . '/index.min.js', $base_file ), + $asset['module_dependencies'] ?? array(), + $asset['version'] ?? false + ); + } + } +} From cc7a0de2bc50dce308ce29e95cbb6746a954e502 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 20:14:11 -0300 Subject: [PATCH 02/40] add wp-build-polyfills package as dependency of forms package --- pnpm-lock.yaml | 3 +++ projects/packages/forms/composer.json | 1 + projects/packages/forms/package.json | 6 ++++-- .../packages/forms/src/dashboard/class-dashboard.php | 11 +++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fb21298bc3..fd5afc622d6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2558,6 +2558,9 @@ importers: '@automattic/jetpack-webpack-config': specifier: workspace:* version: link:../../js-packages/webpack-config + '@automattic/jetpack-wp-build-polyfills': + specifier: workspace:* + version: link:../wp-build-polyfills '@automattic/remove-asset-webpack-plugin': specifier: workspace:* version: link:../../js-packages/remove-asset-webpack-plugin diff --git a/projects/packages/forms/composer.json b/projects/packages/forms/composer.json index 65f86152c7fc..8520b2b828e9 100644 --- a/projects/packages/forms/composer.json +++ b/projects/packages/forms/composer.json @@ -7,6 +7,7 @@ "php": ">=7.2", "automattic/jetpack-blocks": "@dev", "automattic/jetpack-assets": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev", "automattic/jetpack-connection": "@dev", "automattic/jetpack-device-detection": "@dev", "automattic/jetpack-external-connections": "@dev", diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index c562b692d7ec..3e7302a4261c 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -21,7 +21,8 @@ "build:contact-form": "webpack --config ./tools/webpack.config.contact-form.js", "build:dashboard": "webpack --config ./tools/webpack.config.dashboard.js", "build:form-editor": "webpack --config ./tools/webpack.config.form-editor.js", - "build:wp-build": "wp-build", + "build:polyfills": "build-polyfills", + "build:wp-build": "pnpm run build:polyfills && wp-build", "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build && pnpm run validate", "clean": "rm -rf dist/ build/ .cache/", "extract-icons": "node tools/extract-icons.mjs", @@ -35,7 +36,7 @@ "validate": "pnpm exec validate-es --no-error-on-unmatched-pattern dist/", "watch": "concurrently 'pnpm:build:blocks --watch' 'pnpm:build:contact-form --watch' 'pnpm:build:dashboard --watch' 'pnpm:module:watch' 'pnpm:build:form-editor --watch' 'pnpm:watch:wp-build' 'pnpm:watch:icons'", "watch:icons": "node tools/watch-icons.mjs", - "watch:wp-build": "wp-build --watch" + "watch:wp-build": "pnpm run build:polyfills && wp-build --watch" }, "browserslist": [ "extends @wordpress/browserslist-config" @@ -99,6 +100,7 @@ "@automattic/color-studio": "4.1.0", "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-webpack-config": "workspace:*", + "@automattic/jetpack-wp-build-polyfills": "workspace:*", "@automattic/remove-asset-webpack-plugin": "workspace:*", "@babel/core": "7.29.0", "@babel/runtime": "7.28.6", diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 1200ca620141..b80fd10112ba 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -36,9 +36,20 @@ public static function load_wp_build() { : 'inbox'; wp_safe_redirect( self::get_forms_admin_url( $default_tab ) ); + exit; } + // Register polyfills for WP < 7.0 (must run before build.php). + if ( class_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::class ) ) { + $forms_root = dirname( __DIR__, 2 ); + + \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( + $forms_root, + $forms_root . '/build/polyfills/modules/boot/index.min.js' + ); + } + $wp_build_index = dirname( __DIR__, 2 ) . '/build/build.php'; if ( file_exists( $wp_build_index ) ) { From 7150aa0ffd159f54fac0f8079021b9eb902537db Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 20:17:00 -0300 Subject: [PATCH 03/40] Add changelog entries. --- .../forms/changelog/fix-polyfill-wp-build-dependencies | 4 ++++ .../changelog/fix-polyfill-wp-build-dependencies | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 projects/packages/forms/changelog/fix-polyfill-wp-build-dependencies create mode 100644 projects/packages/wp-build-polyfills/changelog/fix-polyfill-wp-build-dependencies diff --git a/projects/packages/forms/changelog/fix-polyfill-wp-build-dependencies b/projects/packages/forms/changelog/fix-polyfill-wp-build-dependencies new file mode 100644 index 000000000000..47f642d23ac9 --- /dev/null +++ b/projects/packages/forms/changelog/fix-polyfill-wp-build-dependencies @@ -0,0 +1,4 @@ +Significance: minor +Type: fixed + +Add polyfills for wp-build unbundled dependencies diff --git a/projects/packages/wp-build-polyfills/changelog/fix-polyfill-wp-build-dependencies b/projects/packages/wp-build-polyfills/changelog/fix-polyfill-wp-build-dependencies new file mode 100644 index 000000000000..383847a6e973 --- /dev/null +++ b/projects/packages/wp-build-polyfills/changelog/fix-polyfill-wp-build-dependencies @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Created wp-build polyfills package From 4d56c4f6f63b861ec781d8b373db2f930f55a495 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 20:25:32 -0300 Subject: [PATCH 04/40] fix changelog --- projects/packages/wp-build-polyfills/CHANGELOG.md | 6 ++++++ projects/packages/wp-build-polyfills/package.json | 2 +- .../jetpack/changelog/fix-polyfill-wp-build-dependencies | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 projects/packages/wp-build-polyfills/CHANGELOG.md create mode 100644 projects/plugins/jetpack/changelog/fix-polyfill-wp-build-dependencies diff --git a/projects/packages/wp-build-polyfills/CHANGELOG.md b/projects/packages/wp-build-polyfills/CHANGELOG.md new file mode 100644 index 000000000000..03a962f457f6 --- /dev/null +++ b/projects/packages/wp-build-polyfills/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index 0383fd3cb5de..7b88a3414b9a 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -1,6 +1,6 @@ { "name": "@automattic/jetpack-wp-build-polyfills", - "version": "0.1.0", + "version": "0.1.0-alpha", "private": true, "description": "Polyfills for WordPress Core packages not available in WP < 7.0", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/wp-build-polyfills/#readme", diff --git a/projects/plugins/jetpack/changelog/fix-polyfill-wp-build-dependencies b/projects/plugins/jetpack/changelog/fix-polyfill-wp-build-dependencies new file mode 100644 index 000000000000..e93330c1de2e --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-polyfill-wp-build-dependencies @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Update composer.lock. From c58c48a4a205249e93380792cb849b6ed1d622fb Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 20:29:12 -0300 Subject: [PATCH 05/40] add .gitattributes --- .../packages/wp-build-polyfills/.gitattributes | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/.gitattributes diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes new file mode 100644 index 000000000000..b0b228d4ad6a --- /dev/null +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -0,0 +1,17 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +.github/ export-ignore +package.json export-ignore + +# Files to include in the mirror repo, but excluded via gitignore +# Remember to end all directories with `/**` to properly tag every file. +# /src/js/example.min.js production-include + +# Files to exclude from the mirror repo, but included in the monorepo. +# Remember to end all directories with `/**` to properly tag every file. +.gitignore production-exclude +changelog/** production-exclude +phpunit.xml.dist production-exclude +.phpcs.dir.xml production-exclude +tests/** production-exclude +.phpcsignore production-exclude From 4d377a95d8431c2f0e813671335a479a99fbcc93 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 21:16:31 -0300 Subject: [PATCH 06/40] Add Phan static analysis config for wp-build-polyfills package Co-Authored-By: Claude Opus 4.6 --- .../packages/wp-build-polyfills/.phan/config.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/.phan/config.php diff --git a/projects/packages/wp-build-polyfills/.phan/config.php b/projects/packages/wp-build-polyfills/.phan/config.php new file mode 100644 index 000000000000..a0a9c626191e --- /dev/null +++ b/projects/packages/wp-build-polyfills/.phan/config.php @@ -0,0 +1,13 @@ + Date: Thu, 26 Feb 2026 21:17:23 -0300 Subject: [PATCH 07/40] fix base_file --- projects/packages/forms/src/dashboard/class-dashboard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index b80fd10112ba..6410782756b6 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -46,7 +46,7 @@ public static function load_wp_build() { \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( $forms_root, - $forms_root . '/build/polyfills/modules/boot/index.min.js' + $forms_root . '/composer.json' // Used only for plugins_url() computation, as it is a known file. ); } From 139cc0c7201bf940eb3941cdbe11ea1ebbaa9b8f Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 21:57:43 -0300 Subject: [PATCH 08/40] Remove executable bit from build-polyfills.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bin entry in package.json handles execution permissions via npm/pnpm — no need for the git executable bit. Co-Authored-By: Claude Opus 4.6 --- projects/packages/wp-build-polyfills/bin/build-polyfills.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 projects/packages/wp-build-polyfills/bin/build-polyfills.mjs diff --git a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs old mode 100755 new mode 100644 From d0b45e578e2029ed994dce2f34d749cf539e1707 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 26 Feb 2026 22:35:45 -0300 Subject: [PATCH 09/40] Exclude bin/ from wp-build-polyfills production mirror The bin/build-polyfills.mjs script is a Node.js dev build tool and should not be shipped in the jetpack_vendor directory. Co-Authored-By: Claude Opus 4.6 --- projects/packages/wp-build-polyfills/.gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes index b0b228d4ad6a..00f042d8399d 100644 --- a/projects/packages/wp-build-polyfills/.gitattributes +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -10,6 +10,7 @@ package.json export-ignore # Files to exclude from the mirror repo, but included in the monorepo. # Remember to end all directories with `/**` to properly tag every file. .gitignore production-exclude +bin/** production-exclude changelog/** production-exclude phpunit.xml.dist production-exclude .phpcs.dir.xml production-exclude From d38be0c009571b9d51f4bdb6da1f40fedc07bb52 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 2 Mar 2026 20:56:55 -0300 Subject: [PATCH 10/40] Add @wordpress/notices polyfill and fix autoloader resolution The Forms wp-build dashboard failed with "Element type is invalid" when Gutenberg was disabled because @wordpress/boot imports SnackbarNotices from @wordpress/notices, which is not exported by WordPress core < 7.0. - Add @wordpress/notices as a classic script polyfill that force-replaces core's registration on WP < 7.0 - Move polyfill builds into wp-build-polyfills package itself (self-contained build with own scripts, .gitignore, .gitattributes) - Remove forms package dependency on wp-build-polyfills npm workspace (now only a composer dependency for the PHP autoloader) - Update Jetpack plugin composer.lock to resolve wp-build-polyfills as a transitive dependency through forms, fixing class_exists() returning false - Clean up debug error_log statements Co-Authored-By: Claude Opus 4.6 --- projects/packages/forms/package.json | 6 +- .../forms/src/dashboard/class-dashboard.php | 7 +- .../wp-build-polyfills/.gitattributes | 1 + .../packages/wp-build-polyfills/.gitignore | 2 +- .../bin/build-polyfills.mjs | 39 +++++----- .../packages/wp-build-polyfills/composer.json | 8 +++ .../packages/wp-build-polyfills/package.json | 7 +- .../src/class-wp-build-polyfills.php | 72 ++++++++++--------- projects/plugins/jetpack/composer.lock | 50 ++++++++++++- 9 files changed, 125 insertions(+), 67 deletions(-) diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index 3e7302a4261c..c562b692d7ec 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -21,8 +21,7 @@ "build:contact-form": "webpack --config ./tools/webpack.config.contact-form.js", "build:dashboard": "webpack --config ./tools/webpack.config.dashboard.js", "build:form-editor": "webpack --config ./tools/webpack.config.form-editor.js", - "build:polyfills": "build-polyfills", - "build:wp-build": "pnpm run build:polyfills && wp-build", + "build:wp-build": "wp-build", "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build && pnpm run validate", "clean": "rm -rf dist/ build/ .cache/", "extract-icons": "node tools/extract-icons.mjs", @@ -36,7 +35,7 @@ "validate": "pnpm exec validate-es --no-error-on-unmatched-pattern dist/", "watch": "concurrently 'pnpm:build:blocks --watch' 'pnpm:build:contact-form --watch' 'pnpm:build:dashboard --watch' 'pnpm:module:watch' 'pnpm:build:form-editor --watch' 'pnpm:watch:wp-build' 'pnpm:watch:icons'", "watch:icons": "node tools/watch-icons.mjs", - "watch:wp-build": "pnpm run build:polyfills && wp-build --watch" + "watch:wp-build": "wp-build --watch" }, "browserslist": [ "extends @wordpress/browserslist-config" @@ -100,7 +99,6 @@ "@automattic/color-studio": "4.1.0", "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-webpack-config": "workspace:*", - "@automattic/jetpack-wp-build-polyfills": "workspace:*", "@automattic/remove-asset-webpack-plugin": "workspace:*", "@babel/core": "7.29.0", "@babel/runtime": "7.28.6", diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 6410782756b6..4d9d9248f0bd 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -42,12 +42,7 @@ public static function load_wp_build() { // Register polyfills for WP < 7.0 (must run before build.php). if ( class_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::class ) ) { - $forms_root = dirname( __DIR__, 2 ); - - \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( - $forms_root, - $forms_root . '/composer.json' // Used only for plugins_url() computation, as it is a known file. - ); + \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(); } $wp_build_index = dirname( __DIR__, 2 ) . '/build/build.php'; diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes index 00f042d8399d..9b74a0e6fee5 100644 --- a/projects/packages/wp-build-polyfills/.gitattributes +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -6,6 +6,7 @@ package.json export-ignore # Files to include in the mirror repo, but excluded via gitignore # Remember to end all directories with `/**` to properly tag every file. # /src/js/example.min.js production-include +build/** production-include # Files to exclude from the mirror repo, but included in the monorepo. # Remember to end all directories with `/**` to properly tag every file. diff --git a/projects/packages/wp-build-polyfills/.gitignore b/projects/packages/wp-build-polyfills/.gitignore index 65f08b37e4fe..48368aef75da 100644 --- a/projects/packages/wp-build-polyfills/.gitignore +++ b/projects/packages/wp-build-polyfills/.gitignore @@ -1,3 +1,3 @@ .cache/ /node_modules -build/ +/build diff --git a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs index 17baad90405b..82f0ee4c9fb6 100644 --- a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs +++ b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env node - /** * Build script for Core package polyfills. * @@ -7,6 +5,11 @@ * (private-apis, theme, boot, route, a11y) so that plugins using * wp-build can conditionally register them when Core/Gutenberg doesn't provide them. * + * Uses esbuild rather than webpack because these are infrastructure packages + * without translatable strings, so i18n / translate.wordpress.org support is not + * needed. The `.asset.php` metadata generation is minimal and handled directly + * here — there is no equivalent outside webpack's dependency-extraction plugin. + * * Uses the same externals strategy as wp-build's wordpress-externals-plugin: * - Classic scripts (IIFE): `@wordpress/*` → window.wp.{camelCase}, vendor → globals * - Script modules (ESM): `@wordpress/*` script modules → external (import map), @@ -17,24 +20,16 @@ import { createHash } from 'crypto'; import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { createRequire } from 'module'; import path from 'path'; -import { fileURLToPath } from 'url'; -import { parseArgs } from 'util'; +import process from 'process'; import { build } from 'esbuild'; // Resolve packages from this package's own node_modules, not the consumer's. -const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); -const packageRoot = path.resolve( __dirname, '..' ); +const __dirname = import.meta.dirname; +const packageRoot = path.dirname( __dirname ); const require = createRequire( path.join( packageRoot, 'package.json' ) ); -// Parse CLI arguments. -const { values: args } = parseArgs( { - options: { - 'output-dir': { type: 'string', default: 'build/polyfills' }, - }, - strict: false, -} ); - -const outputBase = path.resolve( args[ 'output-dir' ] ); +const isProduction = process.env.NODE_ENV === 'production'; +const outputBase = path.join( packageRoot, 'build' ); // ── Vendor externals (same as wp-build) ────────────────────────────────────── @@ -309,6 +304,12 @@ function resolvePackageEntry( packageName, subEntry = null ) { } const classicScriptPolyfills = [ + { + name: 'notices', + packageName: '@wordpress/notices', + globalName: 'wp.notices', + entry: resolvePackageEntry( '@wordpress/notices' ), + }, { name: 'private-apis', packageName: '@wordpress/private-apis', @@ -361,7 +362,7 @@ for ( const polyfill of classicScriptPolyfills ) { target, platform: 'browser', minify: true, - sourcemap: true, + sourcemap: isProduction, plugins: [ polyfillExternalsPlugin( 'iife', polyfill.packageName ) ], } ) ); @@ -377,7 +378,7 @@ for ( const polyfill of classicScriptPolyfills ) { target, platform: 'browser', minify: false, - sourcemap: true, + sourcemap: ! isProduction, plugins: [ polyfillExternalsPlugin( 'iife', polyfill.packageName ) ], } ) ); @@ -398,7 +399,7 @@ for ( const polyfill of scriptModulePolyfills ) { target, platform: 'browser', minify: true, - sourcemap: true, + sourcemap: isProduction, plugins: [ polyfillExternalsPlugin( 'esm', polyfill.packageName ) ], } ) ); @@ -413,7 +414,7 @@ for ( const polyfill of scriptModulePolyfills ) { target, platform: 'browser', minify: false, - sourcemap: true, + sourcemap: ! isProduction, plugins: [ polyfillExternalsPlugin( 'esm', polyfill.packageName ) ], } ) ); diff --git a/projects/packages/wp-build-polyfills/composer.json b/projects/packages/wp-build-polyfills/composer.json index 2efe5a3a0d8c..ee2ce03d6951 100644 --- a/projects/packages/wp-build-polyfills/composer.json +++ b/projects/packages/wp-build-polyfills/composer.json @@ -23,6 +23,14 @@ } } ], + "scripts": { + "build-production": [ + "pnpm run build-production" + ], + "build-development": [ + "pnpm run build" + ] + }, "minimum-stability": "dev", "prefer-stable": true, "extra": { diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index 7b88a3414b9a..590c03798804 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -11,12 +11,15 @@ }, "license": "GPL-2.0-or-later", "author": "Automattic", - "bin": { - "build-polyfills": "bin/build-polyfills.mjs" + "scripts": { + "build": "pnpm run clean && node bin/build-polyfills.mjs", + "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build", + "clean": "rm -rf build/" }, "devDependencies": { "@wordpress/a11y": "^4.40.0", "@wordpress/boot": "^0.7.1", + "@wordpress/notices": "^5.40.0", "@wordpress/private-apis": "^1.40.0", "@wordpress/route": "^0.6.0", "@wordpress/theme": "^0.7.0", diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index 720715ae208a..e625bf031bd9 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -21,25 +21,17 @@ class WP_Build_Polyfills { * * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts * at priority 20 so Core (default) and Gutenberg (priority 10) register first. - * - * @param string $base_dir Absolute path to directory containing build/polyfills/. - * @param string $base_file File path for plugins_url() computation. */ - public static function register( $base_dir, $base_file ) { - $polyfills_dir = $base_dir . '/build/polyfills'; - - add_action( - 'wp_default_scripts', - function ( $scripts ) use ( $polyfills_dir, $base_file ) { - self::register_scripts( $scripts, $polyfills_dir, $base_file ); - }, - 20 - ); + public static function register() { + $package_root = dirname( __DIR__ ); + $build_dir = $package_root . '/build'; + $base_file = $package_root . '/composer.json'; add_action( 'wp_default_scripts', - function () use ( $polyfills_dir, $base_file ) { - self::register_modules( $polyfills_dir, $base_file ); + function ( $scripts ) use ( $build_dir, $base_file ) { + self::register_scripts( $scripts, $build_dir, $base_file ); + self::register_modules( $build_dir, $base_file ); }, 20 ); @@ -48,28 +40,37 @@ function () use ( $polyfills_dir, $base_file ) { /** * Register polyfill classic scripts. * - * @param \WP_Scripts $scripts The WP_Scripts instance. - * @param string $polyfills_dir Absolute path to the polyfills build directory. - * @param string $base_file File path for plugins_url() computation. + * @param \WP_Scripts $scripts The WP_Scripts instance. + * @param string $build_dir Absolute path to the build directory. + * @param string $base_file File path for plugins_url() computation. */ - private static function register_scripts( $scripts, $polyfills_dir, $base_file ) { + private static function register_scripts( $scripts, $build_dir, $base_file ) { $polyfills = array( + 'wp-notices' => array( + 'path' => 'notices', + // Only force-replace on WP < 7.0: older Core versions ship + // notices without SnackbarNotices and InlineNotices component + // exports that @wordpress/boot depends on. + 'force' => version_compare( $GLOBALS['wp_version'] ?? '0', '7.0-dev', '<' ), + ), 'wp-private-apis' => array( 'path' => 'private-apis', - 'deps' => array(), - // Always replace: older Core versions ship private-apis with an - // incomplete allowlist that rejects @wordpress/theme and @wordpress/route. + // Only force-replace on WP < 7.0: older Core versions ship + // private-apis with an incomplete allowlist that rejects + // @wordpress/theme and @wordpress/route. // Our version is a strict superset (same API, larger allowlist). - 'force' => true, + 'force' => version_compare( $GLOBALS['wp_version'] ?? '0', '7.0-dev', '<' ), ), 'wp-theme' => array( 'path' => 'theme', - 'deps' => array( 'wp-element', 'wp-private-apis' ), ), ); + $use_minified = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? false : true; + $file_name = $use_minified ? 'index.min' : 'index'; + foreach ( $polyfills as $handle => $data ) { - $asset_file = $polyfills_dir . '/scripts/' . $data['path'] . '/index.min.asset.php'; + $asset_file = $build_dir . '/scripts/' . $data['path'] . '/' . $file_name . '.asset.php'; if ( ! file_exists( $asset_file ) ) { continue; @@ -90,9 +91,9 @@ private static function register_scripts( $scripts, $polyfills_dir, $base_file ) $scripts->add( $handle, - plugins_url( 'build/polyfills/scripts/' . $data['path'] . '/index.min.js', $base_file ), - $asset['dependencies'] ?? $data['deps'], - $asset['version'] ?? false + plugins_url( 'build/scripts/' . $data['path'] . '/' . $file_name . '.js', $base_file ), + $asset['dependencies'], + $asset['version'] ); } } @@ -103,19 +104,22 @@ private static function register_scripts( $scripts, $polyfills_dir, $base_file ) * Call to wp_register_script_module() silently ignores duplicate registrations (first wins), * so no explicit is_registered check is needed. * - * @param string $polyfills_dir Absolute path to the polyfills build directory. - * @param string $base_file File path for plugins_url() computation. + * @param string $build_dir Absolute path to the build directory. + * @param string $base_file File path for plugins_url() computation. */ - private static function register_modules( $polyfills_dir, $base_file ) { + private static function register_modules( $build_dir, $base_file ) { if ( ! function_exists( 'wp_register_script_module' ) ) { return; } $modules = array( 'boot', 'route', 'a11y' ); + $use_minified = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? false : true; + $file_name = $use_minified ? 'index.min' : 'index'; + foreach ( $modules as $name ) { $module_id = '@wordpress/' . $name; - $asset_file = $polyfills_dir . '/modules/' . $name . '/index.min.asset.php'; + $asset_file = $build_dir . '/modules/' . $name . '/' . $file_name . '.asset.php'; if ( ! file_exists( $asset_file ) ) { continue; @@ -125,9 +129,9 @@ private static function register_modules( $polyfills_dir, $base_file ) { wp_register_script_module( $module_id, - plugins_url( 'build/polyfills/modules/' . $name . '/index.min.js', $base_file ), + plugins_url( 'build/modules/' . $name . '/' . $file_name . '.js', $base_file ), $asset['module_dependencies'] ?? array(), - $asset['version'] ?? false + $asset['version'] ); } } diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 3c7b6c365888..2f5c9d6c3842 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -1493,7 +1493,7 @@ "dist": { "type": "path", "url": "../../packages/forms", - "reference": "50f87f787265e2f589b8ecd51407ffe4013e607f" + "reference": "1dd808dbc752ca6926a692969c704fb12fc233bc" }, "require": { "automattic/jetpack-admin-ui": "@dev", @@ -1507,6 +1507,7 @@ "automattic/jetpack-plans": "@dev", "automattic/jetpack-status": "@dev", "automattic/jetpack-sync": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev", "php": ">=7.2" }, "require-dev": { @@ -3580,6 +3581,53 @@ "relative": true } }, + { + "name": "automattic/jetpack-wp-build-polyfills", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/wp-build-polyfills", + "reference": "e0245feb632318f808a68138bdc18b09dd39ce2e" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev" + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-wp-build-polyfills", + "textdomain": "jetpack-wp-build-polyfills", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-wp-build-polyfills/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-production": [ + "pnpm run build-production" + ], + "build-development": [ + "pnpm run build" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Polyfills for WordPress Core packages not available in WP < 7.0", + "transport-options": { + "relative": true + } + }, { "name": "automattic/woocommerce-analytics", "version": "dev-trunk", From 7c0cf3535e148864737bdd348949c8752e6b3a39 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 2 Mar 2026 21:03:58 -0300 Subject: [PATCH 11/40] Build only one variant per mode in wp-build-polyfills Production builds produce minified files (index.min.js), development builds produce unminified files (index.js). This matches the Jetpack monorepo convention and avoids the waste of building both variants. Co-Authored-By: Claude Opus 4.6 --- .../bin/build-polyfills.mjs | 55 +++++-------------- 1 file changed, 13 insertions(+), 42 deletions(-) diff --git a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs index 82f0ee4c9fb6..ce392249d8a2 100644 --- a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs +++ b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs @@ -348,37 +348,26 @@ const scriptModulePolyfills = [ const target = [ 'es2020' ]; const builds = []; +// Production builds produce minified files (index.min.js); +// development builds produce unminified files (index.js). +// The PHP registration class selects the appropriate file at runtime +// via SCRIPT_DEBUG, and skips registration if the file doesn't exist. +const outFileName = isProduction ? 'index.min.js' : 'index.js'; + for ( const polyfill of classicScriptPolyfills ) { const outputDir = path.join( outputBase, 'scripts', polyfill.name ); - // Minified - builds.push( - build( { - entryPoints: [ polyfill.entry ], - outfile: path.join( outputDir, 'index.min.js' ), - bundle: true, - format: 'iife', - globalName: polyfill.globalName, - target, - platform: 'browser', - minify: true, - sourcemap: isProduction, - plugins: [ polyfillExternalsPlugin( 'iife', polyfill.packageName ) ], - } ) - ); - - // Non-minified builds.push( build( { entryPoints: [ polyfill.entry ], - outfile: path.join( outputDir, 'index.js' ), + outfile: path.join( outputDir, outFileName ), bundle: true, format: 'iife', globalName: polyfill.globalName, target, platform: 'browser', - minify: false, - sourcemap: ! isProduction, + minify: isProduction, + sourcemap: true, plugins: [ polyfillExternalsPlugin( 'iife', polyfill.packageName ) ], } ) ); @@ -387,34 +376,16 @@ for ( const polyfill of classicScriptPolyfills ) { for ( const polyfill of scriptModulePolyfills ) { const outputDir = path.join( outputBase, 'modules', polyfill.name ); - const entryPoint = polyfill.entry; - - // Minified - builds.push( - build( { - entryPoints: [ entryPoint ], - outfile: path.join( outputDir, 'index.min.js' ), - bundle: true, - format: 'esm', - target, - platform: 'browser', - minify: true, - sourcemap: isProduction, - plugins: [ polyfillExternalsPlugin( 'esm', polyfill.packageName ) ], - } ) - ); - - // Non-minified builds.push( build( { - entryPoints: [ entryPoint ], - outfile: path.join( outputDir, 'index.js' ), + entryPoints: [ polyfill.entry ], + outfile: path.join( outputDir, outFileName ), bundle: true, format: 'esm', target, platform: 'browser', - minify: false, - sourcemap: ! isProduction, + minify: isProduction, + sourcemap: true, plugins: [ polyfillExternalsPlugin( 'esm', polyfill.packageName ) ], } ) ); From 461fc92f4f46f99a3489bff0f7c1ee0518a373aa Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 2 Mar 2026 22:20:34 -0300 Subject: [PATCH 12/40] Replace esbuild with webpack for wp-build-polyfills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the wp-build-polyfills build from a custom esbuild script to a standard webpack configuration using @automattic/jetpack-webpack-config. IIFE builds (classic scripts) use the dependency-extraction-webpack-plugin via StandardPlugins for externals handling and .asset.php generation. ESM builds (script modules) use a custom PolyfillModulePlugin that handles hybrid externals — script module deps become `import @wordpress/route`, classic-only deps become `var wp.notices` window globals — and generates the combined dependencies/module_dependencies asset files. This addresses the review feedback about reinventing webpack with esbuild, and aligns with the monorepo's standard build tooling. Also simplifies the PHP class to always use `index.js` (no min/non-min variants), matching the Jetpack convention where minification is controlled by the build mode (jetpack build vs jetpack build --production). Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 141 ++++++- .../wp-build-polyfills/.gitattributes | 5 +- .../packages/wp-build-polyfills/.gitignore | 2 +- .../bin/build-polyfills.mjs | 396 ------------------ .../packages/wp-build-polyfills/package.json | 6 +- .../src/class-wp-build-polyfills.php | 14 +- .../wp-build-polyfills/webpack.config.js | 338 +++++++++++++++ 7 files changed, 488 insertions(+), 414 deletions(-) delete mode 100644 projects/packages/wp-build-polyfills/bin/build-polyfills.mjs create mode 100644 projects/packages/wp-build-polyfills/webpack.config.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd5afc622d6d..0d12133943b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2558,9 +2558,6 @@ importers: '@automattic/jetpack-webpack-config': specifier: workspace:* version: link:../../js-packages/webpack-config - '@automattic/jetpack-wp-build-polyfills': - specifier: workspace:* - version: link:../wp-build-polyfills '@automattic/remove-asset-webpack-plugin': specifier: workspace:* version: link:../../js-packages/remove-asset-webpack-plugin @@ -4169,6 +4166,43 @@ importers: specifier: 6.0.1 version: 6.0.1(webpack@5.105.2) + projects/packages/wp-build-polyfills: + devDependencies: + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config + '@wordpress/a11y': + specifier: ^4.40.0 + version: 4.41.0 + '@wordpress/boot': + specifier: ^0.7.1 + version: 0.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/notices': + specifier: ^5.40.0 + version: 5.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': + specifier: ^1.40.0 + version: 1.41.0 + '@wordpress/route': + specifier: ^0.6.0 + version: 0.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/theme': + specifier: ^0.7.0 + version: 0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + webpack: + specifier: ^5.104.1 + version: 5.105.2(webpack-cli@6.0.1) + webpack-cli: + specifier: ^6.0.1 + version: 6.0.1(webpack@5.105.2) + optionalDependencies: + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + projects/packages/wp-js-data-sync: {} projects/packages/yoast-promo: @@ -10486,6 +10520,13 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/boot@0.7.1': + resolution: {integrity: sha512-rfoPJiDYCt4odZBmc3CL2C8wL+uH6L9Jfg+udTBk6sLx81MSgRJlhAmcH6T8LO1vemrdsChk8Z84VBO428glAA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/boot@0.8.0': resolution: {integrity: sha512-vyjmPZXWC0FgoU27JO/8KVZVNLwELKgiSB6FO/DATy22zQBmOuqIBbGKhsf2MazPYpkwe+l5ozBp2hGgIX/ofg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10840,6 +10881,12 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/route@0.6.0': + resolution: {integrity: sha512-NL0JZx/KoV0rHQhua8Py+rj/Z/KfyQusRw72yCk0Ei9sna1cYywAqzZNJfKxRr+Ua2YwJSy7ybueL0/OPZ0izw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/route@0.7.0': resolution: {integrity: sha512-6gv0hXb+bhGNK7bLAdcbMoDOdrXFRfsJfSN1P8hm429myZESmhYrh0mEV4at4y44C06zGPTqyzSKH0sR0eCMkg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10889,6 +10936,17 @@ packages: stylelint: optional: true + '@wordpress/theme@0.7.0': + resolution: {integrity: sha512-ULwLCSKYraIsv83bVH+Hm5pGFen6/0/8xOXQwxMdxeU+8kSm0cTKlpQPNvJGCmAeQb2OgFcowB/8wrUdyqW8UQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + stylelint: '>=16.8.2' + peerDependenciesMeta: + stylelint: + optional: true + '@wordpress/theme@0.8.0': resolution: {integrity: sha512-xXGjWNFHICBuMNfjCjTui5ChkiKmmPTJtsF5tPXnUBXJaw43xxGlL0y7lpCNPJQxz+NPMJ01KlGfxhRsHXjKrQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -24027,6 +24085,40 @@ snapshots: simple-html-tokenizer: 0.5.11 uuid: 9.0.1(patch_hash=87a713b75995ed86c0ecd0cadfd1b9f85092ca16fdff7132ff98b62fc3cf2db0) + '@wordpress/boot@0.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1)': + dependencies: + '@wordpress/a11y': 4.41.0 + '@wordpress/admin-ui': 1.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/base-styles': 6.17.0 + '@wordpress/commands': 1.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/components': 32.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': 7.41.0(react@18.3.1) + '@wordpress/core-data': 7.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/data': 10.41.0(react@18.3.1) + '@wordpress/editor': 14.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/element': 6.41.0 + '@wordpress/html-entities': 4.41.0 + '@wordpress/i18n': 6.14.0 + '@wordpress/icons': 11.8.0(react@18.3.1) + '@wordpress/keyboard-shortcuts': 5.41.0(react@18.3.1) + '@wordpress/keycodes': 4.41.0 + '@wordpress/lazy-editor': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/notices': 5.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/primitives': 4.41.0(react@18.3.1) + '@wordpress/private-apis': 1.41.0 + '@wordpress/route': 0.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/theme': 0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/url': 4.41.0 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - '@types/react-dom' + - stylelint + - supports-color + '@wordpress/boot@0.8.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1)': dependencies: '@wordpress/a11y': 4.41.0 @@ -25325,6 +25417,29 @@ snapshots: - stylelint - supports-color + '@wordpress/lazy-editor@1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1)': + dependencies: + '@wordpress/asset-loader': 1.7.0 + '@wordpress/base-styles': 6.17.0 + '@wordpress/block-editor': 15.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/blocks': 15.14.0(react@18.3.1) + '@wordpress/components': 32.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/core-data': 7.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/data': 10.41.0(react@18.3.1) + '@wordpress/editor': 14.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/element': 6.41.0 + '@wordpress/global-styles-engine': 1.8.0(react@18.3.1) + '@wordpress/i18n': 6.14.0 + '@wordpress/private-apis': 1.41.0 + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - '@types/react-dom' + - react + - react-dom + - stylelint + - supports-color + '@wordpress/media-editor@0.4.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1)': dependencies: '@babel/runtime': 7.28.6 @@ -25745,6 +25860,15 @@ snapshots: memize: 2.1.1 react: 18.3.1 + '@wordpress/route@0.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-router': 1.166.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': 1.41.0 + react: 18.3.1 + transitivePeerDependencies: + - react-dom + '@wordpress/route@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.161.4 @@ -25825,6 +25949,17 @@ snapshots: optionalDependencies: stylelint: 16.26.1 + '@wordpress/theme@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1)': + dependencies: + '@wordpress/element': 6.41.0 + '@wordpress/private-apis': 1.41.0 + colorjs.io: 0.6.1 + memize: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + stylelint: 16.26.1 + '@wordpress/theme@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1(typescript@5.9.3))': dependencies: '@wordpress/element': 6.41.0 diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes index 9b74a0e6fee5..e520e59fbc62 100644 --- a/projects/packages/wp-build-polyfills/.gitattributes +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -10,8 +10,9 @@ build/** production-include # Files to exclude from the mirror repo, but included in the monorepo. # Remember to end all directories with `/**` to properly tag every file. -.gitignore production-exclude -bin/** production-exclude +.gitignore production-exclude +bin/** production-exclude +webpack.config.js production-exclude changelog/** production-exclude phpunit.xml.dist production-exclude .phpcs.dir.xml production-exclude diff --git a/projects/packages/wp-build-polyfills/.gitignore b/projects/packages/wp-build-polyfills/.gitignore index 48368aef75da..83a5ab2f4c63 100644 --- a/projects/packages/wp-build-polyfills/.gitignore +++ b/projects/packages/wp-build-polyfills/.gitignore @@ -1,3 +1,3 @@ -.cache/ /node_modules /build +/.cache diff --git a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs b/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs deleted file mode 100644 index ce392249d8a2..000000000000 --- a/projects/packages/wp-build-polyfills/bin/build-polyfills.mjs +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Build script for Core package polyfills. - * - * Bundles `@wordpress` packages that are not available in WordPress Core < 7.0 - * (private-apis, theme, boot, route, a11y) so that plugins using - * wp-build can conditionally register them when Core/Gutenberg doesn't provide them. - * - * Uses esbuild rather than webpack because these are infrastructure packages - * without translatable strings, so i18n / translate.wordpress.org support is not - * needed. The `.asset.php` metadata generation is minimal and handled directly - * here — there is no equivalent outside webpack's dependency-extraction plugin. - * - * Uses the same externals strategy as wp-build's wordpress-externals-plugin: - * - Classic scripts (IIFE): `@wordpress/*` → window.wp.{camelCase}, vendor → globals - * - Script modules (ESM): `@wordpress/*` script modules → external (import map), - * `@wordpress/*` classic-only → window.wp.{camelCase}, vendor → globals - */ - -import { createHash } from 'crypto'; -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { createRequire } from 'module'; -import path from 'path'; -import process from 'process'; -import { build } from 'esbuild'; - -// Resolve packages from this package's own node_modules, not the consumer's. -const __dirname = import.meta.dirname; -const packageRoot = path.dirname( __dirname ); -const require = createRequire( path.join( packageRoot, 'package.json' ) ); - -const isProduction = process.env.NODE_ENV === 'production'; -const outputBase = path.join( packageRoot, 'build' ); - -// ── Vendor externals (same as wp-build) ────────────────────────────────────── - -const vendorExternals = { - react: { global: 'React', handle: 'react' }, - 'react-dom': { global: 'ReactDOM', handle: 'react-dom' }, - 'react/jsx-runtime': { - global: 'ReactJSXRuntime', - handle: 'react-jsx-runtime', - }, - 'react/jsx-dev-runtime': { - global: 'ReactJSXRuntime', - handle: 'react-jsx-runtime', - }, - moment: { global: 'moment', handle: 'moment' }, - lodash: { global: 'lodash', handle: 'lodash' }, - 'lodash-es': { global: 'lodash', handle: 'lodash' }, - jquery: { global: 'jQuery', handle: 'jquery' }, -}; - -// ── Package info cache ─────────────────────────────────────────────────────── - -const packageJsonCache = new Map(); - -/** - * Get the package JSON for a package. - * @param {string} packageName - The package name. - * @param {string} resolveDir - The directory to resolve the package from. - * @return {object} The package JSON. - */ -function getPackageJson( packageName, resolveDir = null ) { - const contextDir = resolveDir || packageRoot; - const cacheKey = `${ packageName }@${ contextDir }`; - - if ( packageJsonCache.has( cacheKey ) ) { - return packageJsonCache.get( cacheKey ); - } - - try { - const contextRequire = createRequire( path.join( contextDir, 'package.json' ) ); - - const pkgPath = contextRequire.resolve( `${ packageName }/package.json` ); - - const pkg = JSON.parse( readFileSync( pkgPath, 'utf8' ) ); - packageJsonCache.set( cacheKey, pkg ); - - return pkg; - } catch { - packageJsonCache.set( cacheKey, null ); - - return null; - } -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Convert a string to camel case. - * - * @param {string} str - The string to convert. - * @return {string} The camel case string. - */ -function camelCase( str ) { - return str.replace( /-([a-z])/g, ( _, c ) => c.toUpperCase() ); -} - -/** - * Check if a subpath is a script module import. - * @param {object} packageJson - The package JSON object. - * @param {string} subpath - The subpath to check. - * @return {boolean} Whether the subpath is a script module import. - */ -function isScriptModuleImport( packageJson, subpath ) { - const { wpScriptModuleExports } = packageJson; - - if ( ! wpScriptModuleExports ) { - return false; - } - - if ( ! subpath ) { - if ( typeof wpScriptModuleExports === 'string' ) { - return true; - } - - if ( typeof wpScriptModuleExports === 'object' && wpScriptModuleExports[ '.' ] ) { - return true; - } - - return false; - } - - if ( typeof wpScriptModuleExports === 'object' && wpScriptModuleExports[ `./${ subpath }` ] ) { - return true; - } - - return false; -} - -/** - * Generate a SHA-256 hash of the content. - * @param {string} content - The content to hash. - * @return {string} The SHA-256 hash. - */ -function generateContentHash( content ) { - return createHash( 'sha256' ).update( content ).digest( 'hex' ).slice( 0, 20 ); -} - -// ── Externals plugin ───────────────────────────────────────────────────────── - -/** - * Create an externals plugin for the polyfills. - * @param {string} buildFormat - The build format. - * @param {string} skipPackage - The package to skip. - * @return {object} The externals plugin. - */ -function polyfillExternalsPlugin( buildFormat, skipPackage = null ) { - const dependencies = new Set(); - const moduleDependencies = new Map(); - - return { - name: 'polyfill-externals', - setup( esb ) { - // Vendor externals - for ( const [ packageName, config ] of Object.entries( vendorExternals ) ) { - esb.onResolve( { filter: new RegExp( `^${ packageName }$` ) }, onResolveArgs => { - dependencies.add( config.handle ); - - return { - path: onResolveArgs.path, - namespace: 'vendor-external', - pluginData: { global: config.global }, - }; - } ); - } - - // @wordpress/* externals - esb.onResolve( { filter: /^@wordpress\// }, onResolveArgs => { - const parts = onResolveArgs.path.split( '/' ); - const packageName = parts.slice( 0, 2 ).join( '/' ); - const subpath = parts.length > 2 ? parts.slice( 2 ).join( '/' ) : null; - const shortName = parts[ 1 ]; - const handle = `wp-${ shortName }`; - - // Don't externalize the package we're building - if ( skipPackage && packageName === skipPackage ) { - return undefined; - } - - const packageJson = getPackageJson( packageName, onResolveArgs.resolveDir ); - - if ( ! packageJson ) { - return undefined; - } - - let isScriptModule = isScriptModuleImport( packageJson, subpath ); - let isScript = !! packageJson.wpScript; - - // Dual packages: use the format being built - if ( isScriptModule && isScript ) { - isScript = buildFormat === 'iife'; - isScriptModule = buildFormat === 'esm'; - } - - const kind = onResolveArgs.kind === 'dynamic-import' ? 'dynamic' : 'static'; - - if ( isScriptModule ) { - if ( kind === 'static' ) { - moduleDependencies.set( onResolveArgs.path, 'static' ); - } else if ( ! moduleDependencies.has( onResolveArgs.path ) ) { - moduleDependencies.set( onResolveArgs.path, 'dynamic' ); - } - - return { - path: onResolveArgs.path, - external: true, - sideEffects: !! packageJson.sideEffects, - }; - } - - if ( isScript ) { - dependencies.add( handle ); - - return { - path: onResolveArgs.path, - namespace: 'package-external', - pluginData: { globalName: 'wp' }, - }; - } - - // Not a registered script or module — let esbuild bundle it - return undefined; - } ); - - esb.onLoad( { filter: /.*/, namespace: 'vendor-external' }, onLoadArgs => ( { - contents: `module.exports = window.${ onLoadArgs.pluginData.global };`, - loader: 'js', - } ) ); - - esb.onLoad( { filter: /.*/, namespace: 'package-external' }, onLoadArgs => { - const packagePath = onLoadArgs.path.split( '/' ).slice( 1 ).join( '/' ); - const name = camelCase( packagePath ); - - return { - contents: `module.exports = window.${ onLoadArgs.pluginData.globalName }.${ name };`, - loader: 'js', - }; - } ); - - // Generate asset file on build end - esb.onEnd( result => { - if ( result.errors.length > 0 ) { - return; - } - - const outfile = esb.initialOptions.outfile; - const outputDir = path.dirname( outfile ); - const baseName = path.basename( outfile, '.js' ); - - // Read the output to hash it - const outputContent = readFileSync( outfile ); - const version = generateContentHash( outputContent ); - - const sortedDeps = Array.from( dependencies ).sort(); - const depsString = sortedDeps.map( d => `'${ d }'` ).join( ', ' ); - - const assetParts = [ `'dependencies' => array(${ depsString })` ]; - - if ( moduleDependencies.size > 0 ) { - const modDeps = Array.from( moduleDependencies.entries() ) - .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) - .map( ( [ dep, impKind ] ) => `array('id' => '${ dep }', 'import' => '${ impKind }')` ) - .join( ', ' ); - - assetParts.push( `'module_dependencies' => array(${ modDeps })` ); - } - - assetParts.push( `'version' => '${ version }'` ); - - const assetContent = ` $data ) { - $asset_file = $build_dir . '/scripts/' . $data['path'] . '/' . $file_name . '.asset.php'; + $asset_file = $build_dir . '/scripts/' . $data['path'] . '/index.asset.php'; if ( ! file_exists( $asset_file ) ) { continue; @@ -91,7 +88,7 @@ private static function register_scripts( $scripts, $build_dir, $base_file ) { $scripts->add( $handle, - plugins_url( 'build/scripts/' . $data['path'] . '/' . $file_name . '.js', $base_file ), + plugins_url( 'build/scripts/' . $data['path'] . '/index.js', $base_file ), $asset['dependencies'], $asset['version'] ); @@ -114,12 +111,9 @@ private static function register_modules( $build_dir, $base_file ) { $modules = array( 'boot', 'route', 'a11y' ); - $use_minified = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? false : true; - $file_name = $use_minified ? 'index.min' : 'index'; - foreach ( $modules as $name ) { $module_id = '@wordpress/' . $name; - $asset_file = $build_dir . '/modules/' . $name . '/' . $file_name . '.asset.php'; + $asset_file = $build_dir . '/modules/' . $name . '/index.asset.php'; if ( ! file_exists( $asset_file ) ) { continue; @@ -129,7 +123,7 @@ private static function register_modules( $build_dir, $base_file ) { wp_register_script_module( $module_id, - plugins_url( 'build/modules/' . $name . '/' . $file_name . '.js', $base_file ), + plugins_url( 'build/modules/' . $name . '/index.js', $base_file ), $asset['module_dependencies'] ?? array(), $asset['version'] ); diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js new file mode 100644 index 000000000000..a30ac969e51c --- /dev/null +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -0,0 +1,338 @@ +/** + * Webpack configuration for wp-build-polyfills. + * + * Bundles `@wordpress` packages not available in WordPress Core < 7.0 + * as both classic scripts (IIFE) and script modules (ESM). + * + * IIFE builds use `@wordpress/dependency-extraction-webpack-plugin` (via + * jetpack-webpack-config's StandardPlugins) for externals and .asset.php. + * + * ESM builds use a custom PolyfillModulePlugin because the dep extraction + * plugin doesn't support hybrid externals — ESM modules that import both + * script modules (externalized as `import \@wordpress/route`) and classic + * scripts (externalized as `var wp.notices` window globals). + */ + +const { readFileSync } = require( 'fs' ); +const path = require( 'path' ); +const jetpackWebpackConfig = require( '@automattic/jetpack-webpack-config/webpack' ); + +const packageRoot = __dirname; +const localRequire = require; + +const outputBase = path.join( packageRoot, 'build' ); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Convert a dash-separated string to camelCase. + * + * @param {string} str - Input string. + * @return {string} camelCased string. + */ +function camelCaseDash( str ) { + return str.replace( /-([a-z])/g, ( _, c ) => c.toUpperCase() ); +} + +const pkgJsonCache = new Map(); + +/** + * Read and cache a package's package.json. + * + * @param {string} packageName - npm package name. + * @return {object|null} Parsed package.json or null. + */ +function readPackageJson( packageName ) { + if ( pkgJsonCache.has( packageName ) ) { + return pkgJsonCache.get( packageName ); + } + try { + const pkgPath = localRequire.resolve( `${ packageName }/package.json` ); + const pkg = JSON.parse( readFileSync( pkgPath, 'utf8' ) ); + pkgJsonCache.set( packageName, pkg ); + return pkg; + } catch { + pkgJsonCache.set( packageName, null ); + return null; + } +} + +/** + * Check if a package exports a WordPress script module (default export). + * + * @param {object} pkg - Parsed package.json. + * @return {boolean} True if the package has wpScriptModuleExports for '.'. + */ +function hasScriptModuleExport( pkg ) { + if ( ! pkg?.wpScriptModuleExports ) { + return false; + } + const exports = pkg.wpScriptModuleExports; + return typeof exports === 'string' || ( typeof exports === 'object' && exports[ '.' ] ); +} + +/** + * Resolve the entry point for a package. + * + * Some packages (e.g. `@wordpress/boot`) only export ESM, so CJS + * require.resolve() fails. We resolve via package.json instead and + * read the `module` or `main` field. + * + * @param {string} packageName - npm package name. + * @param {string|null} subEntry - Optional sub-entry relative to package root. + * @return {string} Absolute path to the entry file. + */ +function resolveEntry( packageName, subEntry = null ) { + const pkgPath = localRequire.resolve( `${ packageName }/package.json` ); + const pkgDir = path.dirname( pkgPath ); + if ( subEntry ) { + return path.join( pkgDir, subEntry ); + } + const pkg = JSON.parse( readFileSync( pkgPath, 'utf8' ) ); + return path.join( pkgDir, pkg.module || pkg.main ); +} + +// ── Shared config ─────────────────────────────────────────────────────────── + +const sharedConfig = { + mode: jetpackWebpackConfig.mode, + devtool: jetpackWebpackConfig.devtool, + optimization: { + ...jetpackWebpackConfig.optimization, + }, + resolve: { + ...jetpackWebpackConfig.resolve, + modules: [ path.join( packageRoot, 'node_modules' ), 'node_modules' ], + }, + module: { + rules: [ + // Transpile @wordpress/* packages from node_modules. + jetpackWebpackConfig.TranspileRule( { + includeNodeModules: [ '@wordpress/' ], + } ), + ], + }, +}; + +// Plugins disabled for all polyfill builds: no CSS is bundled, and i18n is +// handled by core's textdomain since @wordpress/i18n remains external. +const disabledPlugins = { + MiniCssExtractPlugin: false, + MiniCssWithRtlPlugin: false, + WebpackRtlPlugin: false, + I18nLoaderPlugin: false, + I18nCheckPlugin: false, +}; + +// ── Polyfill definitions ──────────────────────────────────────────────────── + +const classicPolyfills = [ + { + name: 'notices', + packageName: '@wordpress/notices', + library: [ 'wp', 'notices' ], + }, + { + name: 'private-apis', + packageName: '@wordpress/private-apis', + library: [ 'wp', 'privateApis' ], + }, + { + name: 'theme', + packageName: '@wordpress/theme', + library: [ 'wp', 'theme' ], + }, +]; + +const modulePolyfills = [ + { name: 'boot', packageName: '@wordpress/boot' }, + { name: 'route', packageName: '@wordpress/route' }, + { + name: 'a11y', + packageName: '@wordpress/a11y', + // a11y's wpScriptModuleExports points to a separate module entry. + subEntry: 'build-module/module/index.mjs', + }, +]; + +// ── IIFE configs (classic scripts) ────────────────────────────────────────── +// +// Uses @wordpress/dependency-extraction-webpack-plugin (via StandardPlugins) +// for externals handling and .asset.php generation. The requestMap prevents +// externalization of the package being polyfilled so webpack bundles it. + +const iifeConfigs = classicPolyfills.map( polyfill => ( { + name: `script-${ polyfill.name }`, + ...sharedConfig, + entry: { + index: resolveEntry( polyfill.packageName ), + }, + output: { + ...jetpackWebpackConfig.output, + path: path.join( outputBase, 'scripts', polyfill.name ), + library: { + name: polyfill.library, + type: 'window', + }, + }, + plugins: [ + ...jetpackWebpackConfig.StandardPlugins( { + DependencyExtractionPlugin: { + requestMap: { + [ polyfill.packageName ]: { external: false }, + }, + }, + ...disabledPlugins, + } ), + ], +} ) ); + +// ── PolyfillModulePlugin ──────────────────────────────────────────────────── +// +// Custom webpack plugin for ESM polyfill builds. Handles: +// 1. Externals via webpack.ExternalsPlugin — script module deps become +// `import @wordpress/xxx`, classic-only deps become `var wp.xxx`. +// 2. Dependency tracking — separates classic script handles (dependencies) +// from module IDs (module_dependencies). +// 3. Asset generation — writes .asset.php in the same format as +// @wordpress/dependency-extraction-webpack-plugin. + +// Vendor externals (same mapping as @wordpress/dependency-extraction-webpack-plugin). +const VENDOR_EXTERNALS = { + react: { global: 'React', handle: 'react' }, + 'react-dom': { global: 'ReactDOM', handle: 'react-dom' }, + 'react/jsx-runtime': { global: 'ReactJSXRuntime', handle: 'react-jsx-runtime' }, + 'react/jsx-dev-runtime': { global: 'ReactJSXRuntime', handle: 'react-jsx-runtime' }, + moment: { global: 'moment', handle: 'moment' }, + lodash: { global: 'lodash', handle: 'lodash' }, + 'lodash-es': { global: 'lodash', handle: 'lodash' }, + jquery: { global: 'jQuery', handle: 'jquery' }, +}; + +class PolyfillModulePlugin { + constructor( { skipPackage } ) { + this.skipPackage = skipPackage; + } + + apply( compiler ) { + const { webpack } = compiler; + const { RawSource } = webpack.sources; + + const scriptDeps = new Set(); + const moduleDeps = new Map(); + + // Register externals. + new webpack.ExternalsPlugin( 'import', ( { request }, callback ) => { + // Don't externalize the package being polyfilled. + if ( request === this.skipPackage || request.startsWith( this.skipPackage + '/' ) ) { + return callback(); + } + + // @wordpress/* packages. + if ( request.startsWith( '@wordpress/' ) ) { + const pkgName = request.split( '/' ).slice( 0, 2 ).join( '/' ); + const shortName = request.split( '/' )[ 1 ]; + const pkg = readPackageJson( pkgName ); + + // Prefer script module for ESM builds. + if ( pkg && hasScriptModuleExport( pkg ) ) { + moduleDeps.set( request, 'static' ); + return callback( null, `import ${ request }` ); + } + + // Classic script (or unresolvable — default to classic since + // most @wordpress/* packages are classic scripts). + scriptDeps.add( `wp-${ shortName }` ); + return callback( null, `var wp.${ camelCaseDash( shortName ) }` ); + } + + // Vendor externals. + const vendor = VENDOR_EXTERNALS[ request ]; + if ( vendor ) { + scriptDeps.add( vendor.handle ); + return callback( null, `var ${ vendor.global }` ); + } + + // Unknown — let webpack bundle it. + return callback(); + } ).apply( compiler ); + + // Generate .asset.php files. + compiler.hooks.thisCompilation.tap( 'PolyfillModulePlugin', compilation => { + compilation.hooks.processAssets.tap( + { + name: 'PolyfillModulePlugin', + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => { + for ( const [ , entrypoint ] of compilation.entrypoints ) { + for ( const chunk of entrypoint.chunks ) { + const jsFile = Array.from( chunk.files ).find( f => /\.m?js$/i.test( f ) ); + if ( ! jsFile ) { + continue; + } + + const content = compilation.getAsset( jsFile ).source.buffer(); + const hash = webpack.util + .createHash( 'xxhash64' ) + .update( content ) + .digest( 'hex' ) + .slice( 0, 16 ); + + const depsPhp = Array.from( scriptDeps ) + .sort() + .map( d => `'${ d }'` ) + .join( ', ' ); + const modDepsPhp = Array.from( moduleDeps.entries() ) + .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) + .map( ( [ id, imp ] ) => `array('id' => '${ id }', 'import' => '${ imp }')` ) + .join( ', ' ); + + const parts = [ `'dependencies' => array(${ depsPhp })` ]; + if ( moduleDeps.size > 0 ) { + parts.push( `'module_dependencies' => array(${ modDepsPhp })` ); + } + parts.push( `'version' => '${ hash }'` ); + + const assetContent = ` ( { + name: `module-${ polyfill.name }`, + ...sharedConfig, + entry: { + index: resolveEntry( polyfill.packageName, polyfill.subEntry ), + }, + output: { + ...jetpackWebpackConfig.output, + path: path.join( outputBase, 'modules', polyfill.name ), + module: true, + chunkFormat: 'module', + environment: { module: true }, + library: { type: 'module' }, + }, + experiments: { + outputModule: true, + }, + plugins: [ + ...jetpackWebpackConfig.StandardPlugins( { + DependencyExtractionPlugin: false, + ...disabledPlugins, + } ), + new PolyfillModulePlugin( { skipPackage: polyfill.packageName } ), + ], +} ) ); + +module.exports = [ ...iifeConfigs, ...esmConfigs ]; From a771285da1183f1d3f4c4cf42718cc9a83f22e0e Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 2 Mar 2026 23:32:39 -0300 Subject: [PATCH 13/40] Add PHP tests for wp-build-polyfills Integration tests verifying that polyfills register when Core doesn't provide them, force-replace on WP < 7.0, and skip when Core already has them. Uses WorDBless via jetpack-test-environment. Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/.gitattributes | 2 +- .../packages/wp-build-polyfills/composer.json | 19 +- .../wp-build-polyfills/phpunit.11.xml.dist | 36 ++ .../wp-build-polyfills/phpunit.12.xml.dist | 36 ++ .../wp-build-polyfills/phpunit.9.xml.dist | 17 + .../tests/php/WP_Build_Polyfills_Test.php | 425 ++++++++++++++++++ .../tests/php/bootstrap.php | 12 + 7 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 projects/packages/wp-build-polyfills/phpunit.11.xml.dist create mode 100644 projects/packages/wp-build-polyfills/phpunit.12.xml.dist create mode 100644 projects/packages/wp-build-polyfills/phpunit.9.xml.dist create mode 100644 projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php create mode 100644 projects/packages/wp-build-polyfills/tests/php/bootstrap.php diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes index e520e59fbc62..725ae946a59d 100644 --- a/projects/packages/wp-build-polyfills/.gitattributes +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -14,7 +14,7 @@ build/** production-include bin/** production-exclude webpack.config.js production-exclude changelog/** production-exclude -phpunit.xml.dist production-exclude +phpunit.*.xml.dist production-exclude .phpcs.dir.xml production-exclude tests/** production-exclude .phpcsignore production-exclude diff --git a/projects/packages/wp-build-polyfills/composer.json b/projects/packages/wp-build-polyfills/composer.json index ee2ce03d6951..651ed25e6326 100644 --- a/projects/packages/wp-build-polyfills/composer.json +++ b/projects/packages/wp-build-polyfills/composer.json @@ -7,7 +7,10 @@ "php": ">=7.2" }, "require-dev": { - "automattic/jetpack-changelogger": "@dev" + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" }, "autoload": { "classmap": [ @@ -29,8 +32,22 @@ ], "build-development": [ "pnpm run build" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" ] }, + "config": { + "allow-plugins": { + "roots/wordpress-core-installer": true + } + }, "minimum-stability": "dev", "prefer-stable": true, "extra": { diff --git a/projects/packages/wp-build-polyfills/phpunit.11.xml.dist b/projects/packages/wp-build-polyfills/phpunit.11.xml.dist new file mode 100644 index 000000000000..f7418373829b --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.11.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + src + + + + + diff --git a/projects/packages/wp-build-polyfills/phpunit.12.xml.dist b/projects/packages/wp-build-polyfills/phpunit.12.xml.dist new file mode 100644 index 000000000000..f7418373829b --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.12.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + src + + + + + diff --git a/projects/packages/wp-build-polyfills/phpunit.9.xml.dist b/projects/packages/wp-build-polyfills/phpunit.9.xml.dist new file mode 100644 index 000000000000..3965963c485e --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.9.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/php + + + diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php new file mode 100644 index 000000000000..81e33788c6de --- /dev/null +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -0,0 +1,425 @@ +build_dir = sys_get_temp_dir() . '/wp-build-polyfills-test-' . uniqid(); + mkdir( $this->build_dir . '/scripts/notices', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->build_dir . '/scripts/private-apis', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->build_dir . '/scripts/theme', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->build_dir . '/modules/boot', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->build_dir . '/modules/route', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->build_dir . '/modules/a11y', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + + $this->original_wp_version = $GLOBALS['wp_version']; + $this->original_wp_script_modules = $GLOBALS['wp_script_modules'] ?? null; + } + + /** + * Tear down test fixtures. + * + * @after + */ + #[After] + public function tear_down() { + $GLOBALS['wp_version'] = $this->original_wp_version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + if ( null === $this->original_wp_script_modules ) { + unset( $GLOBALS['wp_script_modules'] ); + } else { + $GLOBALS['wp_script_modules'] = $this->original_wp_script_modules; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + + $this->recursive_rmdir( $this->build_dir ); + + parent::tear_down(); + } + + /** + * Create a fake asset file. + * + * @param string $path Relative path within the build dir (e.g. "scripts/notices/index.asset.php"). + * @param array $deps Dependencies array. + * @param string $version Version string. + * @param array $extra Extra keys to merge into the asset array. + */ + private function create_asset_file( $path, $deps = array(), $version = '1.0.0', $extra = array() ) { + $data = array_merge( + array( + 'dependencies' => $deps, + 'version' => $version, + ), + $extra + ); + $contents = 'build_dir . '/' . $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + + /** + * Create a WP_Scripts instance with polyfill handles removed. + * + * WP_Scripts::__construct() fires wp_default_scripts which registers core + * scripts. We remove the three handles under test so tests start clean. + * + * @return \WP_Scripts + */ + private function create_clean_scripts() { + $scripts = new \WP_Scripts(); + $scripts->remove( 'wp-notices' ); + $scripts->remove( 'wp-private-apis' ); + $scripts->remove( 'wp-theme' ); + return $scripts; + } + + /** + * Invoke the private register_scripts method. + * + * @param \WP_Scripts $scripts WP_Scripts instance. + */ + private function invoke_register_scripts( $scripts ) { + $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_scripts' ); + $method->setAccessible( true ); + $method->invoke( null, $scripts, $this->build_dir, __FILE__ ); + } + + /** + * Invoke the private register_modules method. + */ + private function invoke_register_modules() { + $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_modules' ); + $method->setAccessible( true ); + $method->invoke( null, $this->build_dir, __FILE__ ); + } + + /** + * Check if a script module is registered. + * + * @param string $id Module ID. + * @return bool + */ + private function is_module_registered( $id ) { + $registered = $this->get_registered_modules(); + return isset( $registered[ $id ] ); + } + + /** + * Get data for a registered module. + * + * @param string $id Module ID. + * @return array|null + */ + private function get_module_data( $id ) { + $registered = $this->get_registered_modules(); + return $registered[ $id ] ?? null; + } + + /** + * Get the private $registered property from WP_Script_Modules. + * + * @return array + */ + private function get_registered_modules() { + $instance = wp_script_modules(); + $prop = new \ReflectionProperty( $instance, 'registered' ); + $prop->setAccessible( true ); + return $prop->getValue( $instance ); + } + + /** + * Recursively remove a directory. + * + * @param string $dir Directory path. + */ + private function recursive_rmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $items as $item ) { + if ( $item->isDir() ) { + rmdir( $item->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } else { + unlink( $item->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + } + } + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } + + /** + * Test that all scripts are registered when all asset files exist. + */ + public function test_register_scripts_registers_all_when_asset_files_exist() { + $this->create_asset_file( 'scripts/notices/index.asset.php' ); + $this->create_asset_file( 'scripts/private-apis/index.asset.php' ); + $this->create_asset_file( 'scripts/theme/index.asset.php' ); + + $scripts = $this->create_clean_scripts(); + $this->invoke_register_scripts( $scripts ); + + $this->assertNotFalse( $scripts->query( 'wp-notices', 'registered' ) ); + $this->assertNotFalse( $scripts->query( 'wp-private-apis', 'registered' ) ); + $this->assertNotFalse( $scripts->query( 'wp-theme', 'registered' ) ); + } + + /** + * Test that no scripts are registered when asset files are missing. + */ + public function test_register_scripts_skips_when_asset_files_missing() { + $scripts = $this->create_clean_scripts(); + $this->invoke_register_scripts( $scripts ); + + $this->assertFalse( $scripts->query( 'wp-notices', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-private-apis', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-theme', 'registered' ) ); + } + + /** + * Test that only scripts with asset files are registered. + */ + public function test_register_scripts_registers_only_scripts_with_asset_files() { + $this->create_asset_file( 'scripts/notices/index.asset.php' ); + // No asset file for private-apis or theme. + + $scripts = $this->create_clean_scripts(); + $this->invoke_register_scripts( $scripts ); + + $this->assertNotFalse( $scripts->query( 'wp-notices', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-private-apis', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-theme', 'registered' ) ); + } + + /** + * Test that wp-theme (non-force) keeps existing registration. + */ + public function test_register_scripts_skips_wp_theme_when_already_registered() { + $this->create_asset_file( 'scripts/theme/index.asset.php', array(), '2.0.0' ); + + $scripts = $this->create_clean_scripts(); + $scripts->add( 'wp-theme', 'https://example.com/original-theme.js', array(), '1.0.0-original' ); + + $this->invoke_register_scripts( $scripts ); + + $registered = $scripts->query( 'wp-theme', 'registered' ); + $this->assertNotFalse( $registered ); + $this->assertSame( '1.0.0-original', $registered->ver ); + } + + /** + * Test that wp-notices is force-replaced on WP < 7.0. + */ + public function test_register_scripts_force_replaces_wp_notices_on_old_wp() { + $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); + + $scripts = $this->create_clean_scripts(); + $scripts->add( 'wp-notices', 'https://example.com/old-notices.js', array(), '1.0.0-old' ); + + $this->invoke_register_scripts( $scripts ); + + $registered = $scripts->query( 'wp-notices', 'registered' ); + $this->assertNotFalse( $registered ); + $this->assertSame( '9.9.9', $registered->ver ); + } + + /** + * Test that wp-private-apis is force-replaced on WP < 7.0. + */ + public function test_register_scripts_force_replaces_wp_private_apis_on_old_wp() { + $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '9.9.9' ); + + $scripts = $this->create_clean_scripts(); + $scripts->add( 'wp-private-apis', 'https://example.com/old-private-apis.js', array(), '1.0.0-old' ); + + $this->invoke_register_scripts( $scripts ); + + $registered = $scripts->query( 'wp-private-apis', 'registered' ); + $this->assertNotFalse( $registered ); + $this->assertSame( '9.9.9', $registered->ver ); + } + + /** + * Test that neither wp-notices nor wp-private-apis is force-replaced on WP >= 7.0. + */ + public function test_register_scripts_does_not_force_replace_on_wp_7() { + $GLOBALS['wp_version'] = '7.0'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); + $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '9.9.9' ); + + $scripts = $this->create_clean_scripts(); + $scripts->add( 'wp-notices', 'https://example.com/core-notices.js', array(), '1.0.0-core' ); + $scripts->add( 'wp-private-apis', 'https://example.com/core-private-apis.js', array(), '1.0.0-core' ); + + $this->invoke_register_scripts( $scripts ); + + $notices = $scripts->query( 'wp-notices', 'registered' ); + $this->assertSame( '1.0.0-core', $notices->ver ); + + $private_apis = $scripts->query( 'wp-private-apis', 'registered' ); + $this->assertSame( '1.0.0-core', $private_apis->ver ); + } + + /** + * Test that force scripts register fine even when not pre-existing. + */ + public function test_register_scripts_force_registers_fresh_on_old_wp() { + $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); + $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '8.8.8' ); + + $scripts = $this->create_clean_scripts(); + $this->invoke_register_scripts( $scripts ); + + $notices = $scripts->query( 'wp-notices', 'registered' ); + $this->assertNotFalse( $notices ); + $this->assertSame( '9.9.9', $notices->ver ); + + $private_apis = $scripts->query( 'wp-private-apis', 'registered' ); + $this->assertNotFalse( $private_apis ); + $this->assertSame( '8.8.8', $private_apis->ver ); + } + + /** + * Test that dependencies from asset files are passed through correctly. + */ + public function test_register_scripts_has_correct_dependencies() { + $this->create_asset_file( 'scripts/notices/index.asset.php', array( 'wp-element', 'wp-data' ) ); + + $scripts = $this->create_clean_scripts(); + $this->invoke_register_scripts( $scripts ); + + $registered = $scripts->query( 'wp-notices', 'registered' ); + $this->assertNotFalse( $registered ); + $this->assertSame( array( 'wp-element', 'wp-data' ), $registered->deps ); + } + + /** + * Test that all modules are registered when asset files exist. + */ + public function test_register_modules_registers_all_when_asset_files_exist() { + // Reset the script modules global so we start fresh. + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $this->create_asset_file( + 'modules/boot/index.asset.php', + array(), + '1.0.0', + array( 'module_dependencies' => array() ) + ); + $this->create_asset_file( + 'modules/route/index.asset.php', + array(), + '1.0.0', + array( 'module_dependencies' => array() ) + ); + $this->create_asset_file( + 'modules/a11y/index.asset.php', + array(), + '1.0.0', + array( 'module_dependencies' => array() ) + ); + + $this->invoke_register_modules(); + + $this->assertTrue( $this->is_module_registered( '@wordpress/boot' ) ); + $this->assertTrue( $this->is_module_registered( '@wordpress/route' ) ); + $this->assertTrue( $this->is_module_registered( '@wordpress/a11y' ) ); + } + + /** + * Test that no modules are registered when asset files are missing. + */ + public function test_register_modules_skips_when_asset_files_missing() { + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $this->invoke_register_modules(); + + $this->assertFalse( $this->is_module_registered( '@wordpress/boot' ) ); + $this->assertFalse( $this->is_module_registered( '@wordpress/route' ) ); + $this->assertFalse( $this->is_module_registered( '@wordpress/a11y' ) ); + } + + /** + * Test that pre-registered modules are not replaced (first-wins semantics). + */ + public function test_register_modules_does_not_replace_existing() { + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + // Pre-register @wordpress/boot. + wp_register_script_module( '@wordpress/boot', 'https://example.com/core-boot.js', array(), '1.0.0-core' ); + + $this->create_asset_file( + 'modules/boot/index.asset.php', + array(), + '9.9.9', + array( 'module_dependencies' => array() ) + ); + + $this->invoke_register_modules(); + + $module = $this->get_module_data( '@wordpress/boot' ); + $this->assertNotNull( $module ); + $this->assertSame( '1.0.0-core', $module['version'] ); + } + + /** + * Test that register() hooks into wp_default_scripts at priority 20. + */ + public function test_register_hooks_into_wp_default_scripts() { + // Remove any existing hooks so we can verify the exact priority. + remove_all_filters( 'wp_default_scripts' ); + + WP_Build_Polyfills::register(); + + global $wp_filter; + $this->assertArrayHasKey( 'wp_default_scripts', $wp_filter ); + $this->assertArrayHasKey( 20, $wp_filter['wp_default_scripts']->callbacks ); + } +} diff --git a/projects/packages/wp-build-polyfills/tests/php/bootstrap.php b/projects/packages/wp-build-polyfills/tests/php/bootstrap.php new file mode 100644 index 000000000000..f87e4fe524e4 --- /dev/null +++ b/projects/packages/wp-build-polyfills/tests/php/bootstrap.php @@ -0,0 +1,12 @@ + Date: Mon, 2 Mar 2026 23:46:25 -0300 Subject: [PATCH 14/40] Add phpunit.8.xml.dist for CI and update jetpack composer.lock CI runs PHPUnit 8 on older PHP versions, so the config is needed. Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/phpunit.8.xml.dist | 17 +++++++++++++++++ projects/plugins/jetpack/composer.lock | 16 ++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 projects/packages/wp-build-polyfills/phpunit.8.xml.dist diff --git a/projects/packages/wp-build-polyfills/phpunit.8.xml.dist b/projects/packages/wp-build-polyfills/phpunit.8.xml.dist new file mode 100644 index 000000000000..3965963c485e --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.8.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/php + + + diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 2f5c9d6c3842..b13b6b550ac0 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -3587,13 +3587,16 @@ "dist": { "type": "path", "url": "../../packages/wp-build-polyfills", - "reference": "e0245feb632318f808a68138bdc18b09dd39ce2e" + "reference": "e8095b10b4cb4eac8b26dce82529f158aa5346e0" }, "require": { "php": ">=7.2" }, "require-dev": { - "automattic/jetpack-changelogger": "@dev" + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" }, "type": "jetpack-library", "extra": { @@ -3618,6 +3621,15 @@ ], "build-development": [ "pnpm run build" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-php": [ + "@composer phpunit" ] }, "license": [ From 7bdc39120fada789451653b6fccc7eea648217d7 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 2 Mar 2026 23:58:53 -0300 Subject: [PATCH 15/40] Skip setAccessible() on PHP >= 8.1 to avoid 8.5 deprecation Since PHP 8.1, private methods/properties are accessible via reflection without calling setAccessible(). PHP 8.5 deprecates the method, causing CI failures with failOnDeprecation enabled. Co-Authored-By: Claude Opus 4.6 --- .../tests/php/WP_Build_Polyfills_Test.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 81e33788c6de..98da719115f5 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -122,7 +122,9 @@ private function create_clean_scripts() { */ private function invoke_register_scripts( $scripts ) { $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_scripts' ); - $method->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } $method->invoke( null, $scripts, $this->build_dir, __FILE__ ); } @@ -131,7 +133,9 @@ private function invoke_register_scripts( $scripts ) { */ private function invoke_register_modules() { $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_modules' ); - $method->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } $method->invoke( null, $this->build_dir, __FILE__ ); } @@ -165,7 +169,9 @@ private function get_module_data( $id ) { private function get_registered_modules() { $instance = wp_script_modules(); $prop = new \ReflectionProperty( $instance, 'registered' ); - $prop->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $prop->setAccessible( true ); + } return $prop->getValue( $instance ); } From 33535836400fcc3b24af44d343f221e058addf21 Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 3 Mar 2026 19:32:21 -0300 Subject: [PATCH 16/40] Preserve native dynamic imports in boot polyfill When webpack bundles @wordpress/boot, it converts dynamic `import(variable)` calls into context-module stubs that always throw "Cannot find module". This breaks route loading because boot uses `import(route.route_module)` to load route modules via the browser's import map at runtime. Add a webpack loader that injects `/* webpackIgnore: true */` into dynamic import() calls with non-string-literal arguments, telling webpack to preserve them as native browser imports. Co-Authored-By: Claude Opus 4.6 --- .../preserve-dynamic-imports-loader.js | 22 +++++++++++++++++++ .../wp-build-polyfills/webpack.config.js | 14 ++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js diff --git a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js new file mode 100644 index 000000000000..0b5472b17346 --- /dev/null +++ b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js @@ -0,0 +1,22 @@ +/** + * Webpack loader that preserves dynamic import() calls as native browser imports. + * + * When webpack encounters `import(variable)` (a dynamic import with a non-string + * argument), it creates a "context module" stub that always throws + * "Cannot find module". This is because webpack can't statically determine what + * module will be loaded at runtime. + * + * For packages like `@wordpress`/boot that rely on the browser's import map to + * resolve module IDs at runtime, these dynamic imports must be preserved as + * native `import()` calls. This loader adds `webpackIgnore: true` magic comments + * to such imports, telling webpack to leave them as-is. + * + * Only import() calls with variable arguments are affected. String-literal + * imports like `import("`@wordpress`/a11y")` are left untouched so that webpack's + * externals plugin can handle them normally. + * @param {string} source - The source code to process. + * @return {string} - The processed source code. + */ +module.exports = function preserveDynamicImports( source ) { + return source.replace( /\bimport\(\s*(?!['"`/])/g, 'import(/* webpackIgnore: true */ ' ); +}; diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js index a30ac969e51c..14e3724245bc 100644 --- a/projects/packages/wp-build-polyfills/webpack.config.js +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -312,6 +312,20 @@ class PolyfillModulePlugin { const esmConfigs = modulePolyfills.map( polyfill => ( { name: `module-${ polyfill.name }`, ...sharedConfig, + module: { + rules: [ + ...sharedConfig.module.rules, + // Preserve native dynamic imports in @wordpress/boot so the + // browser can resolve route module IDs via the import map. + // Without this, webpack replaces `import(variable)` calls with + // context-module stubs that always throw "Cannot find module". + { + test: /[\\/]@wordpress[\\/]boot[\\/]/, + enforce: 'post', + loader: path.resolve( packageRoot, 'preserve-dynamic-imports-loader.js' ), + }, + ], + }, entry: { index: resolveEntry( polyfill.packageName, polyfill.subEntry ), }, From 1eb5bbb979b9ef26cacc0c0f187b0489611dee75 Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 4 Mar 2026 20:26:40 -0300 Subject: [PATCH 17/40] Make WP version threshold configurable and fix version comparison - Add $wp_version_threshold parameter to register() (defaults to '7.0') - Change version comparison from '7.0-dev' to '7.0' so polyfills also apply on pre-release builds - Deduplicate version_compare calls into a single $force_replace variable Co-Authored-By: Claude Opus 4.6 --- .../src/class-wp-build-polyfills.php | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index 9c4ee7c5fed1..a58694114d4a 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -21,16 +21,19 @@ class WP_Build_Polyfills { * * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts * at priority 20 so Core (default) and Gutenberg (priority 10) register first. + * + * @param string $wp_version_threshold The WordPress version below which force-replacements + * are applied. Defaults to '7.0'. */ - public static function register() { + public static function register( $wp_version_threshold = '7.0' ) { $package_root = dirname( __DIR__ ); $build_dir = $package_root . '/build'; $base_file = $package_root . '/composer.json'; add_action( 'wp_default_scripts', - function ( $scripts ) use ( $build_dir, $base_file ) { - self::register_scripts( $scripts, $build_dir, $base_file ); + function ( $scripts ) use ( $build_dir, $base_file, $wp_version_threshold ) { + self::register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ); self::register_modules( $build_dir, $base_file ); }, 20 @@ -40,26 +43,29 @@ function ( $scripts ) use ( $build_dir, $base_file ) { /** * Register polyfill classic scripts. * - * @param \WP_Scripts $scripts The WP_Scripts instance. - * @param string $build_dir Absolute path to the build directory. - * @param string $base_file File path for plugins_url() computation. + * @param \WP_Scripts $scripts The WP_Scripts instance. + * @param string $build_dir Absolute path to the build directory. + * @param string $base_file File path for plugins_url() computation. + * @param string $wp_version_threshold WP version below which force-replacements apply. */ - private static function register_scripts( $scripts, $build_dir, $base_file ) { + private static function register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ) { + $force_replace = version_compare( $GLOBALS['wp_version'] ?? '0', $wp_version_threshold, '<' ); + $polyfills = array( 'wp-notices' => array( 'path' => 'notices', - // Only force-replace on WP < 7.0: older Core versions ship + // Only force-replace on older WP: older Core versions ship // notices without SnackbarNotices and InlineNotices component // exports that @wordpress/boot depends on. - 'force' => version_compare( $GLOBALS['wp_version'] ?? '0', '7.0-dev', '<' ), + 'force' => $force_replace, ), 'wp-private-apis' => array( 'path' => 'private-apis', - // Only force-replace on WP < 7.0: older Core versions ship + // Only force-replace on older WP: older Core versions ship // private-apis with an incomplete allowlist that rejects // @wordpress/theme and @wordpress/route. // Our version is a strict superset (same API, larger allowlist). - 'force' => version_compare( $GLOBALS['wp_version'] ?? '0', '7.0-dev', '<' ), + 'force' => $force_replace, ), 'wp-theme' => array( 'path' => 'theme', From 0a487936e400821efe2128973fa538cecd226495 Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 4 Mar 2026 20:37:38 -0300 Subject: [PATCH 18/40] Fix test to pass wp_version_threshold to register_scripts Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 98da719115f5..02fe1bac8a9e 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -125,7 +125,7 @@ private function invoke_register_scripts( $scripts ) { if ( PHP_VERSION_ID < 80100 ) { $method->setAccessible( true ); } - $method->invoke( null, $scripts, $this->build_dir, __FILE__ ); + $method->invoke( null, $scripts, $this->build_dir, __FILE__, '7.0' ); } /** From 20474541a5c2590bffeeac6eeeb47d237b7dcf66 Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 4 Mar 2026 20:48:02 -0300 Subject: [PATCH 19/40] Add README for wp-build-polyfills package Co-Authored-By: Claude Opus 4.6 --- .../packages/wp-build-polyfills/README.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/README.md diff --git a/projects/packages/wp-build-polyfills/README.md b/projects/packages/wp-build-polyfills/README.md new file mode 100644 index 000000000000..c5668a051915 --- /dev/null +++ b/projects/packages/wp-build-polyfills/README.md @@ -0,0 +1,66 @@ +# Jetpack WP Build Polyfills + +Polyfills for WordPress Core packages not yet available in WordPress < 7.0. + +This package conditionally registers `@wordpress/*` packages as both classic scripts (IIFE) and script modules (ESM) when they are not already provided by Core or Gutenberg. +It is intended to be used until WordPress 7.1 is released, at which point versions of WordPress < 7.0 will no longer be supported and this package will no longer be needed. + +## Problem + +WordPress 7.0 introduces several new packages (`@wordpress/boot`, `@wordpress/route`, `@wordpress/theme`, etc.) that plugins built with [`@wordpress/build`](https://github.com/WordPress/gutenberg/tree/trunk/packages/wp-build) depend on. On older WordPress versions, these packages are missing or ship incomplete implementations — for example, `wp-private-apis` has an allowlist that rejects `@wordpress/theme` and `@wordpress/route`, and `wp-notices` lacks component exports that `@wordpress/boot` requires. + +This package provides those missing packages so that plugins using `@wordpress/build` can work on WordPress versions before 7.0. + +## What it polyfills + +### Classic scripts (IIFE) + +| Handle | Source package | Force-replaced on WP < 7.0? | +|-------------------|------------------------|------------------------------| +| `wp-notices` | `@wordpress/notices` | Yes — missing component exports | +| `wp-private-apis` | `@wordpress/private-apis` | Yes — incomplete allowlist | +| `wp-theme` | `@wordpress/theme` | No — only registered if absent | + +### Script modules (ESM) + +| Module ID | Source package | +|--------------------|----------------------| +| `@wordpress/boot` | `@wordpress/boot` | +| `@wordpress/route` | `@wordpress/route` | +| `@wordpress/a11y` | `@wordpress/a11y` | + +Script modules use "first-wins" semantics — if Core or Gutenberg already registered the module, the polyfill is silently ignored. + +## How it works + +1. `WP_Build_Polyfills::register()` hooks into `wp_default_scripts` at **priority 20**, after Core (priority 0) and Gutenberg (priority 10) have registered their scripts. +2. For each polyfill, it checks whether a built asset file exists (`build/scripts/*/index.asset.php` or `build/modules/*/index.asset.php`). +3. For classic scripts, it checks whether the handle is already registered. Scripts marked as `force` are deregistered and re-registered with the polyfill version. Non-force scripts are skipped if already registered. +4. For script modules, it calls `wp_register_script_module()`, which silently ignores duplicates. + +## Usage + +Call `register()` early in your plugin — for example, during the main plugin file load: + +```php +\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(); +``` + +The version threshold for force-replacements can be overridden: + +```php +\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( '7.1' ); +``` + +## Development + +```bash +# Build polyfills (development) +pnpm run build + +# Build polyfills (production) +pnpm run build-production + +# Run PHP tests +composer run test-php +``` From 4d9e74edcdb4480f44279ade344529274066eaf8 Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 4 Mar 2026 21:07:32 -0300 Subject: [PATCH 20/40] Add per-handle dependency registration to wp-build-polyfills Consumers now pass a name and a list of polyfill handles they need, enabling tracking of which plugin uses which polyfill for easier future deprecation. Co-Authored-By: Claude Opus 4.6 --- .../forms/src/dashboard/class-dashboard.php | 8 +- .../packages/wp-build-polyfills/README.md | 19 +++- .../src/class-wp-build-polyfills.php | 72 ++++++++++++++- .../tests/php/WP_Build_Polyfills_Test.php | 87 ++++++++++++++++++- 4 files changed, 176 insertions(+), 10 deletions(-) diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 4d9d9248f0bd..46d68d454f42 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -42,7 +42,13 @@ public static function load_wp_build() { // Register polyfills for WP < 7.0 (must run before build.php). if ( class_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::class ) ) { - \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(); + \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( + 'jetpack-forms', + array_merge( + \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES, + \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS + ) + ); } $wp_build_index = dirname( __DIR__, 2 ) . '/build/build.php'; diff --git a/projects/packages/wp-build-polyfills/README.md b/projects/packages/wp-build-polyfills/README.md index c5668a051915..c99d0f9e7992 100644 --- a/projects/packages/wp-build-polyfills/README.md +++ b/projects/packages/wp-build-polyfills/README.md @@ -40,16 +40,27 @@ Script modules use "first-wins" semantics — if Core or Gutenberg already regis ## Usage -Call `register()` early in your plugin — for example, during the main plugin file load: +Call `register()` early in your plugin, specifying a consumer name and the polyfills you need: ```php -\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(); +use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; + +WP_Build_Polyfills::register( 'my-plugin', array( + 'wp-notices', + 'wp-private-apis', + '@wordpress/boot', + '@wordpress/route', +) ); ``` -The version threshold for force-replacements can be overridden: +Available handles are listed in `WP_Build_Polyfills::SCRIPT_HANDLES` and `WP_Build_Polyfills::MODULE_IDS`. + +Multiple plugins can call `register()` — the hook is only added once, and all requested polyfills are merged. You can inspect which consumers requested which polyfills via `WP_Build_Polyfills::get_consumers()`. + +The version threshold for force-replacements can be overridden with a third parameter: ```php -\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( '7.1' ); +WP_Build_Polyfills::register( 'my-plugin', array( 'wp-notices' ), '7.1' ); ``` ## Development diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index a58694114d4a..3e0e7fff607d 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -16,16 +16,62 @@ */ class WP_Build_Polyfills { + /** + * Available polyfill handles for classic scripts. + */ + const SCRIPT_HANDLES = array( 'wp-notices', 'wp-private-apis', 'wp-theme' ); + + /** + * Available polyfill module IDs. + */ + const MODULE_IDS = array( '@wordpress/boot', '@wordpress/route', '@wordpress/a11y' ); + + /** + * Tracks which polyfills have been requested and by which consumers. + * + * Keys are polyfill handles/module IDs, values are arrays of consumer names. + * + * @var array + */ + private static $requested = array(); + + /** + * Whether the wp_default_scripts hook has already been added. + * + * @var bool + */ + private static $hooked = false; + /** * Register polyfill scripts and modules. * * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts * at priority 20 so Core (default) and Gutenberg (priority 10) register first. * - * @param string $wp_version_threshold The WordPress version below which force-replacements - * are applied. Defaults to '7.0'. + * @param string $consumer A unique identifier for the consumer (e.g. plugin slug). + * @param string[] $polyfills List of polyfill handles/module IDs to register. + * Use class constants SCRIPT_HANDLES and MODULE_IDS for reference. + * @param string $wp_version_threshold The WordPress version below which force-replacements + * are applied. Defaults to '7.0'. */ - public static function register( $wp_version_threshold = '7.0' ) { + public static function register( $consumer, $polyfills, $wp_version_threshold = '7.0' ) { + foreach ( $polyfills as $handle ) { + if ( ! in_array( $handle, self::SCRIPT_HANDLES, true ) && ! in_array( $handle, self::MODULE_IDS, true ) ) { + continue; + } + if ( ! isset( self::$requested[ $handle ] ) ) { + self::$requested[ $handle ] = array(); + } + if ( ! in_array( $consumer, self::$requested[ $handle ], true ) ) { + self::$requested[ $handle ][] = $consumer; + } + } + + if ( self::$hooked ) { + return; + } + self::$hooked = true; + $package_root = dirname( __DIR__ ); $build_dir = $package_root . '/build'; $base_file = $package_root . '/composer.json'; @@ -40,6 +86,15 @@ function ( $scripts ) use ( $build_dir, $base_file, $wp_version_threshold ) { ); } + /** + * Get the map of requested polyfills and their consumers. + * + * @return array Keys are polyfill handles/module IDs, values are consumer names. + */ + public static function get_consumers() { + return self::$requested; + } + /** * Register polyfill classic scripts. * @@ -73,6 +128,10 @@ private static function register_scripts( $scripts, $build_dir, $base_file, $wp_ ); foreach ( $polyfills as $handle => $data ) { + if ( ! isset( self::$requested[ $handle ] ) ) { + continue; + } + $asset_file = $build_dir . '/scripts/' . $data['path'] . '/index.asset.php'; if ( ! file_exists( $asset_file ) ) { @@ -118,7 +177,12 @@ private static function register_modules( $build_dir, $base_file ) { $modules = array( 'boot', 'route', 'a11y' ); foreach ( $modules as $name ) { - $module_id = '@wordpress/' . $name; + $module_id = '@wordpress/' . $name; + + if ( ! isset( self::$requested[ $module_id ] ) ) { + continue; + } + $asset_file = $build_dir . '/modules/' . $name . '/index.asset.php'; if ( ! file_exists( $asset_file ) ) { diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 02fe1bac8a9e..23869b7898ca 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -74,6 +74,19 @@ public function tear_down() { $GLOBALS['wp_script_modules'] = $this->original_wp_script_modules; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited } + // Reset static state. + $requested = new \ReflectionProperty( WP_Build_Polyfills::class, 'requested' ); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $requested->setValue( null, array() ); + + $hooked = new \ReflectionProperty( WP_Build_Polyfills::class, 'hooked' ); + if ( PHP_VERSION_ID < 80100 ) { + $hooked->setAccessible( true ); + } + $hooked->setValue( null, false ); + $this->recursive_rmdir( $this->build_dir ); parent::tear_down(); @@ -115,12 +128,43 @@ private function create_clean_scripts() { return $scripts; } + /** + * Request all available polyfills for a test consumer. + * + * Populates the static $requested property so register_scripts/register_modules + * will process all handles. Uses register() but prevents the hook from firing + * by resetting $hooked afterwards. + * + * @param string[] $polyfills Optional specific polyfills to request. Defaults to all. + */ + private function request_polyfills( $polyfills = null ) { + if ( null === $polyfills ) { + $polyfills = array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS ); + } + + // Directly set the $requested static property via reflection. + $requested = new \ReflectionProperty( WP_Build_Polyfills::class, 'requested' ); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $current = $requested->getValue( null ); + foreach ( $polyfills as $handle ) { + if ( ! isset( $current[ $handle ] ) ) { + $current[ $handle ] = array(); + } + $current[ $handle ][] = 'test'; + } + $requested->setValue( null, $current ); + } + /** * Invoke the private register_scripts method. * * @param \WP_Scripts $scripts WP_Scripts instance. */ private function invoke_register_scripts( $scripts ) { + $this->request_polyfills( WP_Build_Polyfills::SCRIPT_HANDLES ); + $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_scripts' ); if ( PHP_VERSION_ID < 80100 ) { $method->setAccessible( true ); @@ -132,6 +176,8 @@ private function invoke_register_scripts( $scripts ) { * Invoke the private register_modules method. */ private function invoke_register_modules() { + $this->request_polyfills( WP_Build_Polyfills::MODULE_IDS ); + $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_modules' ); if ( PHP_VERSION_ID < 80100 ) { $method->setAccessible( true ); @@ -422,10 +468,49 @@ public function test_register_hooks_into_wp_default_scripts() { // Remove any existing hooks so we can verify the exact priority. remove_all_filters( 'wp_default_scripts' ); - WP_Build_Polyfills::register(); + WP_Build_Polyfills::register( 'test-plugin', array( 'wp-notices' ) ); global $wp_filter; $this->assertArrayHasKey( 'wp_default_scripts', $wp_filter ); $this->assertArrayHasKey( 20, $wp_filter['wp_default_scripts']->callbacks ); } + + /** + * Test that get_consumers returns the correct consumer map. + */ + public function test_get_consumers_tracks_polyfill_consumers() { + WP_Build_Polyfills::register( 'plugin-a', array( 'wp-notices', '@wordpress/boot' ) ); + WP_Build_Polyfills::register( 'plugin-b', array( 'wp-notices', 'wp-theme' ) ); + + $consumers = WP_Build_Polyfills::get_consumers(); + + $this->assertSame( array( 'plugin-a', 'plugin-b' ), $consumers['wp-notices'] ); + $this->assertSame( array( 'plugin-a' ), $consumers['@wordpress/boot'] ); + $this->assertSame( array( 'plugin-b' ), $consumers['wp-theme'] ); + $this->assertArrayNotHasKey( 'wp-private-apis', $consumers ); + } + + /** + * Test that only requested polyfills are registered. + */ + public function test_register_scripts_only_registers_requested_handles() { + $this->create_asset_file( 'scripts/notices/index.asset.php' ); + $this->create_asset_file( 'scripts/private-apis/index.asset.php' ); + $this->create_asset_file( 'scripts/theme/index.asset.php' ); + + // Only request wp-notices. + $this->request_polyfills( array( 'wp-notices' ) ); + + $scripts = $this->create_clean_scripts(); + + $method = new \ReflectionMethod( WP_Build_Polyfills::class, 'register_scripts' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + $method->invoke( null, $scripts, $this->build_dir, __FILE__, '7.0' ); + + $this->assertNotFalse( $scripts->query( 'wp-notices', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-private-apis', 'registered' ) ); + $this->assertFalse( $scripts->query( 'wp-theme', 'registered' ) ); + } } From e9ca4f4b2b49e589c0639e48c08d02dd5d5f40da Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 4 Mar 2026 21:16:36 -0300 Subject: [PATCH 21/40] Fix phan issue: omit null arg for static ReflectionProperty::getValue Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 23869b7898ca..6d4454bdda8c 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -147,7 +147,7 @@ private function request_polyfills( $polyfills = null ) { if ( PHP_VERSION_ID < 80100 ) { $requested->setAccessible( true ); } - $current = $requested->getValue( null ); + $current = $requested->getValue(); foreach ( $polyfills as $handle ) { if ( ! isset( $current[ $handle ] ) ) { $current[ $handle ] = array(); From 8c2906bcb6a6dd39f5b6408b74ae88aea605e671 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 6 Mar 2026 20:27:38 -0300 Subject: [PATCH 22/40] Add tests for polyfill registration in Dashboard::load_wp_build Co-Authored-By: Claude Opus 4.6 --- .../tests/php/dashboard/Dashboard_Test.php | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php index d1d740927164..6e6fddf7e67b 100644 --- a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php +++ b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php @@ -7,6 +7,7 @@ namespace Automattic\Jetpack\Forms\Dashboard; +use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; use PHPUnit\Framework\Attributes\CoversClass; use WorDBless\BaseTestCase; @@ -122,6 +123,77 @@ public function test_get_forms_admin_url_wp_build_with_tab() { remove_filter( 'jetpack_forms_alpha', '__return_true' ); } + /** + * Reset WP_Build_Polyfills static state between tests. + */ + private function reset_wp_build_polyfills() { + $ref = new \ReflectionClass( WP_Build_Polyfills::class ); + + $requested = $ref->getProperty( 'requested' ); + $requested->setValue( null, array() ); + + $hooked = $ref->getProperty( 'hooked' ); + $hooked->setValue( null, false ); + } + + /** + * Test load_wp_build registers polyfills when on the wp-build admin page. + */ + public function test_load_wp_build_registers_polyfills_on_wpbuild_page() { + $_GET['page'] = Dashboard::FORMS_WPBUILD_ADMIN_SLUG; + + Dashboard::load_wp_build(); + + $ref = new \ReflectionClass( WP_Build_Polyfills::class ); + $requested = $ref->getProperty( 'requested' ); + $value = $requested->getValue(); + + $expected_handles = array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS ); + + foreach ( $expected_handles as $handle ) { + $this->assertArrayHasKey( $handle, $value, "Polyfill handle '$handle' should be registered." ); + $this->assertContains( 'jetpack-forms', $value[ $handle ], "Consumer 'jetpack-forms' should be registered for '$handle'." ); + } + + $this->reset_wp_build_polyfills(); + unset( $_GET['page'] ); + } + + /** + * Test load_wp_build does not register polyfills when on a different admin page. + */ + public function test_load_wp_build_does_not_register_polyfills_on_other_page() { + $_GET['page'] = 'some-other-page'; + + Dashboard::load_wp_build(); + + $ref = new \ReflectionClass( WP_Build_Polyfills::class ); + $requested = $ref->getProperty( 'requested' ); + $value = $requested->getValue(); + + $this->assertEmpty( $value, 'No polyfills should be registered when on a different page.' ); + + $this->reset_wp_build_polyfills(); + unset( $_GET['page'] ); + } + + /** + * Test load_wp_build does not register polyfills when no page is set. + */ + public function test_load_wp_build_does_not_register_polyfills_without_page() { + unset( $_GET['page'] ); + + Dashboard::load_wp_build(); + + $ref = new \ReflectionClass( WP_Build_Polyfills::class ); + $requested = $ref->getProperty( 'requested' ); + $value = $requested->getValue(); + + $this->assertEmpty( $value, 'No polyfills should be registered when no page is set.' ); + + $this->reset_wp_build_polyfills(); + } + /** * Test is_jetpack_forms_admin_page when get_current_screen is not available */ From a93581d04ad6f5e58a64b1ab1aca946b236f7d33 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 6 Mar 2026 20:57:26 -0300 Subject: [PATCH 23/40] Fix reflection setAccessible for PHP < 8.1 and store version threshold in static property Add setAccessible(true) guards for PHP < 8.1 when accessing private static properties via reflection in tests. Store wp_version_threshold as a static property so multiple register() callers can raise it, with the highest threshold winning. Co-Authored-By: Claude Opus 4.6 --- .../tests/php/dashboard/Dashboard_Test.php | 27 ++++++++++++++++--- .../src/class-wp-build-polyfills.php | 20 ++++++++++++-- .../tests/php/WP_Build_Polyfills_Test.php | 6 +++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php index 6e6fddf7e67b..9fcb313438cb 100644 --- a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php +++ b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php @@ -130,10 +130,22 @@ private function reset_wp_build_polyfills() { $ref = new \ReflectionClass( WP_Build_Polyfills::class ); $requested = $ref->getProperty( 'requested' ); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } $requested->setValue( null, array() ); $hooked = $ref->getProperty( 'hooked' ); + if ( PHP_VERSION_ID < 80100 ) { + $hooked->setAccessible( true ); + } $hooked->setValue( null, false ); + + $threshold = $ref->getProperty( 'wp_version_threshold' ); + if ( PHP_VERSION_ID < 80100 ) { + $threshold->setAccessible( true ); + } + $threshold->setValue( null, '7.0' ); } /** @@ -146,7 +158,10 @@ public function test_load_wp_build_registers_polyfills_on_wpbuild_page() { $ref = new \ReflectionClass( WP_Build_Polyfills::class ); $requested = $ref->getProperty( 'requested' ); - $value = $requested->getValue(); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $value = $requested->getValue(); $expected_handles = array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS ); @@ -169,7 +184,10 @@ public function test_load_wp_build_does_not_register_polyfills_on_other_page() { $ref = new \ReflectionClass( WP_Build_Polyfills::class ); $requested = $ref->getProperty( 'requested' ); - $value = $requested->getValue(); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $value = $requested->getValue(); $this->assertEmpty( $value, 'No polyfills should be registered when on a different page.' ); @@ -187,7 +205,10 @@ public function test_load_wp_build_does_not_register_polyfills_without_page() { $ref = new \ReflectionClass( WP_Build_Polyfills::class ); $requested = $ref->getProperty( 'requested' ); - $value = $requested->getValue(); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $value = $requested->getValue(); $this->assertEmpty( $value, 'No polyfills should be registered when no page is set.' ); diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index 3e0e7fff607d..1d9e4149359e 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -42,12 +42,24 @@ class WP_Build_Polyfills { */ private static $hooked = false; + /** + * The WordPress version below which force-replacements are applied. + * When multiple consumers call register() with different thresholds, + * the highest threshold wins (most conservative approach). + * + * @var string + */ + private static $wp_version_threshold = '7.0'; + /** * Register polyfill scripts and modules. * * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts * at priority 20 so Core (default) and Gutenberg (priority 10) register first. * + * When multiple consumers call this method with different thresholds, the + * highest threshold wins (most conservative — polyfills active on more versions). + * * @param string $consumer A unique identifier for the consumer (e.g. plugin slug). * @param string[] $polyfills List of polyfill handles/module IDs to register. * Use class constants SCRIPT_HANDLES and MODULE_IDS for reference. @@ -67,6 +79,10 @@ public static function register( $consumer, $polyfills, $wp_version_threshold = } } + if ( version_compare( $wp_version_threshold, self::$wp_version_threshold, '>' ) ) { + self::$wp_version_threshold = $wp_version_threshold; + } + if ( self::$hooked ) { return; } @@ -78,8 +94,8 @@ public static function register( $consumer, $polyfills, $wp_version_threshold = add_action( 'wp_default_scripts', - function ( $scripts ) use ( $build_dir, $base_file, $wp_version_threshold ) { - self::register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ); + function ( $scripts ) use ( $build_dir, $base_file ) { + self::register_scripts( $scripts, $build_dir, $base_file, self::$wp_version_threshold ); self::register_modules( $build_dir, $base_file ); }, 20 diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 6d4454bdda8c..7636b5cb4956 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -87,6 +87,12 @@ public function tear_down() { } $hooked->setValue( null, false ); + $threshold = new \ReflectionProperty( WP_Build_Polyfills::class, 'wp_version_threshold' ); + if ( PHP_VERSION_ID < 80100 ) { + $threshold->setAccessible( true ); + } + $threshold->setValue( null, '7.0' ); + $this->recursive_rmdir( $this->build_dir ); parent::tear_down(); From 03509a525cb560839bb2e285280c9bd33e99b43f Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 6 Mar 2026 21:14:42 -0300 Subject: [PATCH 24/40] Fix dynamic import regex to handle leading comments and add additionalAssets flag Update preserve-dynamic-imports-loader regex to correctly handle dynamic imports with leading block/line comments. Add additionalAssets flag to processAssets tap for proper webpack 5 asset emission. Co-Authored-By: Claude Opus 4.6 --- .../preserve-dynamic-imports-loader.js | 9 +++++++-- projects/packages/wp-build-polyfills/webpack.config.js | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js index 0b5472b17346..93f3c9e8bcd5 100644 --- a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js +++ b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js @@ -13,10 +13,15 @@ * * Only import() calls with variable arguments are affected. String-literal * imports like `import("`@wordpress`/a11y")` are left untouched so that webpack's - * externals plugin can handle them normally. + * externals plugin can handle them normally. Dynamic imports with leading + * comments (e.g. `import(/* webpackChunkName: ... *\/ variable)`) are also + * handled correctly. * @param {string} source - The source code to process. * @return {string} - The processed source code. */ module.exports = function preserveDynamicImports( source ) { - return source.replace( /\bimport\(\s*(?!['"`/])/g, 'import(/* webpackIgnore: true */ ' ); + return source.replace( + /\bimport\(\s*(?!(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/|\s)*['"`])/g, + 'import(/* webpackIgnore: true */ ' + ); }; diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js index 14e3724245bc..e514291a663b 100644 --- a/projects/packages/wp-build-polyfills/webpack.config.js +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -263,6 +263,7 @@ class PolyfillModulePlugin { { name: 'PolyfillModulePlugin', stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + additionalAssets: true, }, () => { for ( const [ , entrypoint ] of compilation.entrypoints ) { From c1ba42b4e1cd3ad7a32e5e5d1518124789a8be6a Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 6 Mar 2026 21:35:04 -0300 Subject: [PATCH 25/40] Fix ReDoS vulnerability in dynamic import regex Replace the regex with exponential backtracking risk with a callback-based approach that linearly scans past comments and whitespace to find the first meaningful character. Co-Authored-By: Claude Opus 4.6 --- .../preserve-dynamic-imports-loader.js | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js index 93f3c9e8bcd5..13a9b36b8598 100644 --- a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js +++ b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js @@ -12,7 +12,7 @@ * to such imports, telling webpack to leave them as-is. * * Only import() calls with variable arguments are affected. String-literal - * imports like `import("`@wordpress`/a11y")` are left untouched so that webpack's + * imports like `import("@wordpress/a11y")` are left untouched so that webpack's * externals plugin can handle them normally. Dynamic imports with leading * comments (e.g. `import(/* webpackChunkName: ... *\/ variable)`) are also * handled correctly. @@ -20,8 +20,28 @@ * @return {string} - The processed source code. */ module.exports = function preserveDynamicImports( source ) { - return source.replace( - /\bimport\(\s*(?!(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/|\s)*['"`])/g, - 'import(/* webpackIgnore: true */ ' - ); + return source.replace( /\bimport\(/g, ( match, offset ) => { + // Scan past whitespace and comments to find the first meaningful character. + let i = offset + match.length; + while ( i < source.length ) { + if ( source[ i ] === '/' && source[ i + 1 ] === '*' ) { + const end = source.indexOf( '*/', i + 2 ); + i = end === -1 ? source.length : end + 2; + } else if ( source[ i ] === '/' && source[ i + 1 ] === '/' ) { + const end = source.indexOf( '\n', i + 2 ); + i = end === -1 ? source.length : end + 1; + } else if ( /\s/.test( source[ i ] ) ) { + i++; + } else { + break; + } + } + + // String-literal imports are left for webpack's externals plugin. + if ( i < source.length && /['"`]/.test( source[ i ] ) ) { + return match; + } + + return 'import(/* webpackIgnore: true */ '; + } ); }; From da1b44461d2ed0ccdfee797e1e550e60085bd9d1 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 6 Mar 2026 21:47:14 -0300 Subject: [PATCH 26/40] Address review feedback: docs, production-exclude, and test cleanup - Fix backtick-split package name in loader doc comment - Add preserve-dynamic-imports-loader.js to production-exclude - Add wp-notices to PHP class docblock polyfill list - Move test cleanup to tear_down() so state resets even on failure Co-Authored-By: Claude Opus 4.6 --- .../tests/php/dashboard/Dashboard_Test.php | 17 +++++++++-------- .../packages/wp-build-polyfills/.gitattributes | 1 + .../preserve-dynamic-imports-loader.js | 2 +- .../src/class-wp-build-polyfills.php | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php index 9fcb313438cb..1238531b1528 100644 --- a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php +++ b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php @@ -19,6 +19,15 @@ #[CoversClass( Dashboard::class )] class Dashboard_Test extends BaseTestCase { + /** + * Clean up after each test. + */ + public function tear_down() { + $this->reset_wp_build_polyfills(); + unset( $_GET['page'] ); + parent::tear_down(); + } + /** * Test get_forms_admin_url without tab parameter */ @@ -169,9 +178,6 @@ public function test_load_wp_build_registers_polyfills_on_wpbuild_page() { $this->assertArrayHasKey( $handle, $value, "Polyfill handle '$handle' should be registered." ); $this->assertContains( 'jetpack-forms', $value[ $handle ], "Consumer 'jetpack-forms' should be registered for '$handle'." ); } - - $this->reset_wp_build_polyfills(); - unset( $_GET['page'] ); } /** @@ -190,9 +196,6 @@ public function test_load_wp_build_does_not_register_polyfills_on_other_page() { $value = $requested->getValue(); $this->assertEmpty( $value, 'No polyfills should be registered when on a different page.' ); - - $this->reset_wp_build_polyfills(); - unset( $_GET['page'] ); } /** @@ -211,8 +214,6 @@ public function test_load_wp_build_does_not_register_polyfills_without_page() { $value = $requested->getValue(); $this->assertEmpty( $value, 'No polyfills should be registered when no page is set.' ); - - $this->reset_wp_build_polyfills(); } /** diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes index 725ae946a59d..a8a09c7553b9 100644 --- a/projects/packages/wp-build-polyfills/.gitattributes +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -18,3 +18,4 @@ phpunit.*.xml.dist production-exclude .phpcs.dir.xml production-exclude tests/** production-exclude .phpcsignore production-exclude +preserve-dynamic-imports-loader.js production-exclude diff --git a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js index 13a9b36b8598..16094da8281a 100644 --- a/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js +++ b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js @@ -6,7 +6,7 @@ * "Cannot find module". This is because webpack can't statically determine what * module will be loaded at runtime. * - * For packages like `@wordpress`/boot that rely on the browser's import map to + * For packages like `@wordpress/boot` that rely on the browser's import map to * resolve module IDs at runtime, these dynamic imports must be preserved as * native `import()` calls. This loader adds `webpackIgnore: true` magic comments * to such imports, telling webpack to leave them as-is. diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index 1d9e4149359e..f8ac3e88a1c4 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -2,7 +2,7 @@ /** * Polyfill registration for Core packages not available in WordPress < 7.0. * - * Conditionally registers wp-private-apis, wp-theme (classic scripts) and + * Conditionally registers wp-notices, wp-private-apis, wp-theme (classic scripts) and * `@wordpress/boot`, `@wordpress/route`, `@wordpress/a11y` (script modules) * ONLY when they are not already provided by Core or Gutenberg. * From 74171db8878cb8686e6bcc4769f02fb91983db99 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 21:39:33 -0300 Subject: [PATCH 27/40] Fix PolyfillModulePlugin to match wp-build asset output The PolyfillModulePlugin was generating incorrect dependency metadata compared to wp-build because it couldn't resolve transitive dependencies and lacked dynamic import detection. - Add @wordpress/admin-ui, icons, and lazy-editor as devDependencies so their package.json can be read at build time - Check wpScript field: packages with neither wpScriptModuleExports nor wpScript are now bundled instead of incorrectly externalized - Use parser importCall hook to detect dynamic imports, since webpack 5's ExternalsPlugin reports dependencyType 'esm' for both static and dynamic imports Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 9 ++++ .../packages/wp-build-polyfills/package.json | 3 ++ .../wp-build-polyfills/webpack.config.js | 41 +++++++++++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d12133943b0..8d0c33327687 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4174,9 +4174,18 @@ importers: '@wordpress/a11y': specifier: ^4.40.0 version: 4.41.0 + '@wordpress/admin-ui': + specifier: ^1.9.0 + version: 1.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/boot': specifier: ^0.7.1 version: 0.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) + '@wordpress/icons': + specifier: ^11.7.0 + version: 11.8.0(react@18.3.1) + '@wordpress/lazy-editor': + specifier: ^1.6.1 + version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1) '@wordpress/notices': specifier: ^5.40.0 version: 5.41.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index aac1e9c783f5..8dc8ccbc806b 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -19,7 +19,10 @@ "devDependencies": { "@automattic/jetpack-webpack-config": "workspace:*", "@wordpress/a11y": "^4.40.0", + "@wordpress/admin-ui": "^1.9.0", "@wordpress/boot": "^0.7.1", + "@wordpress/icons": "^11.7.0", + "@wordpress/lazy-editor": "^1.6.1", "@wordpress/notices": "^5.40.0", "@wordpress/private-apis": "^1.40.0", "@wordpress/route": "^0.6.0", diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js index e514291a663b..b865ce110c27 100644 --- a/projects/packages/wp-build-polyfills/webpack.config.js +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -221,6 +221,31 @@ class PolyfillModulePlugin { const scriptDeps = new Set(); const moduleDeps = new Map(); + // Track dynamic import() requests via the parser so we can + // distinguish them from static imports in the externals callback + // (webpack 5's externals API reports 'esm' for both). + const dynamicImportRequests = new Set(); + compiler.hooks.compilation.tap( + 'PolyfillModulePlugin', + ( compilation, { normalModuleFactory } ) => { + const handler = parser => { + parser.hooks.importCall.tap( 'PolyfillModulePlugin', expr => { + // expr.source is the argument to import(). For string + // literals, extract the value. + if ( expr.source && expr.source.type === 'Literal' ) { + dynamicImportRequests.add( expr.source.value ); + } + } ); + }; + normalModuleFactory.hooks.parser + .for( 'javascript/auto' ) + .tap( 'PolyfillModulePlugin', handler ); + normalModuleFactory.hooks.parser + .for( 'javascript/esm' ) + .tap( 'PolyfillModulePlugin', handler ); + } + ); + // Register externals. new webpack.ExternalsPlugin( 'import', ( { request }, callback ) => { // Don't externalize the package being polyfilled. @@ -236,12 +261,22 @@ class PolyfillModulePlugin { // Prefer script module for ESM builds. if ( pkg && hasScriptModuleExport( pkg ) ) { - moduleDeps.set( request, 'static' ); + const kind = dynamicImportRequests.has( request ) ? 'dynamic' : 'static'; + if ( kind === 'static' || ! moduleDeps.has( request ) ) { + moduleDeps.set( request, kind ); + } return callback( null, `import ${ request }` ); } - // Classic script (or unresolvable — default to classic since - // most @wordpress/* packages are classic scripts). + // If package is resolved and has neither wpScriptModuleExports + // nor wpScript, let webpack bundle it (e.g. @wordpress/admin-ui, + // @wordpress/icons). + if ( pkg && ! pkg.wpScript ) { + return callback(); + } + + // Classic script (includes unresolvable packages, which default + // to classic since most @wordpress/* packages are classic scripts). scriptDeps.add( `wp-${ shortName }` ); return callback( null, `var wp.${ camelCaseDash( shortName ) }` ); } From 2af1c6a9610cb720062cd728c7a728968d1142da Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 21:40:33 -0300 Subject: [PATCH 28/40] Add automatic boot module asset proxy for wp-build page consumers When wp-build removes the boot module bundling, page templates will reference a missing asset file. This adds a proxy PHP file to the polyfills package that resolves the asset data via ReflectionClass, and auto-detection in the monorepo CLI build that copies it to the expected location for any package with wp-build pages. Co-Authored-By: Claude Opus 4.6 --- .../src/boot-module-asset-proxy.php | 35 ++++++++++++ tools/cli/commands/build.js | 53 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php diff --git a/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php b/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php new file mode 100644 index 000000000000..755c60c2d7a2 --- /dev/null +++ b/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php @@ -0,0 +1,35 @@ + array(), + 'version' => '', + ); +} + +$ref = new ReflectionClass( $class_name ); +$asset_file = dirname( $ref->getFileName(), 2 ) . '/build/modules/boot/index.asset.php'; + +if ( ! file_exists( $asset_file ) ) { + return array( + 'dependencies' => array(), + 'version' => '', + ); +} + +return require $asset_file; diff --git a/tools/cli/commands/build.js b/tools/cli/commands/build.js index b29022222851..bc1980ead595 100644 --- a/tools/cli/commands/build.js +++ b/tools/cli/commands/build.js @@ -595,6 +595,56 @@ async function checkCollisions( basedir ) { return [ ...collisions ]; } +/** + * Provide the boot module asset proxy for packages using wp-build pages. + * + * When wp-build generates page templates, they reference a boot module asset + * file at build/modules/boot/index.min.asset.php. If the package depends on + * jetpack-wp-build-polyfills and has wp-build pages, this function copies the + * proxy file from the polyfills package to the expected location. + * + * @param {object} t - Task object. + */ +async function provideBootAssetProxy( t ) { + const pagesDir = npath.join( t.cwd, 'build', 'pages' ); + const targetFile = npath.join( t.cwd, 'build', 'modules', 'boot', 'index.min.asset.php' ); + + // Only act if the package has wp-build pages and the file is missing. + if ( ! ( await fsExists( pagesDir ) ) || ( await fsExists( targetFile ) ) ) { + return; + } + + // Look for the proxy in jetpack_vendor (production) or vendor (dev). + const candidates = [ + npath.join( + t.cwd, + 'jetpack_vendor', + 'automattic', + 'jetpack-wp-build-polyfills', + 'src', + 'boot-module-asset-proxy.php' + ), + npath.join( + t.cwd, + 'vendor', + 'automattic', + 'jetpack-wp-build-polyfills', + 'src', + 'boot-module-asset-proxy.php' + ), + ]; + const proxySource = ( + await Promise.all( candidates.map( f => fsExists( f ).then( ok => ok && f ) ) ) + ).find( Boolean ); + if ( ! proxySource ) { + return; + } + + await fs.mkdir( npath.dirname( targetFile ), { recursive: true } ); + await fs.copyFile( proxySource, targetFile ); + await t.output( 'Provided boot module asset proxy from wp-build-polyfills\n' ); +} + /** * Build a project. * @@ -796,6 +846,9 @@ async function buildProject( t ) { } ); } + // Provide the boot module asset proxy for packages using wp-build pages. + await provideBootAssetProxy( t ); + // If we're not mirroring, the build is done. Mirroring has a bunch of stuff to do yet. if ( ! t.argv.forMirrors ) { return; From 1d35705616c6d130d1c66eb4af680f2ce03b5ef0 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 22:01:02 -0300 Subject: [PATCH 29/40] Clear dependency tracking sets between compilations in watch mode The PolyfillModulePlugin's scriptDeps, moduleDeps, and dynamicImportRequests collections were never cleared between compilations, causing stale dependencies to accumulate in watch mode. Co-Authored-By: Claude Opus 4.6 --- projects/packages/wp-build-polyfills/webpack.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js index b865ce110c27..e53db9dff82f 100644 --- a/projects/packages/wp-build-polyfills/webpack.config.js +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -228,6 +228,11 @@ class PolyfillModulePlugin { compiler.hooks.compilation.tap( 'PolyfillModulePlugin', ( compilation, { normalModuleFactory } ) => { + // Clear stale deps from previous compilations (watch mode). + scriptDeps.clear(); + moduleDeps.clear(); + dynamicImportRequests.clear(); + const handler = parser => { parser.hooks.importCall.tap( 'PolyfillModulePlugin', expr => { // expr.source is the argument to import(). For string From 4933b7baf031a0b8da878b48cdd0df02289a030e Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 22:06:32 -0300 Subject: [PATCH 30/40] Document boot module asset proxy in wp-build-polyfills README Co-Authored-By: Claude Opus 4.6 --- projects/packages/wp-build-polyfills/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/projects/packages/wp-build-polyfills/README.md b/projects/packages/wp-build-polyfills/README.md index c99d0f9e7992..f3dbdde56afc 100644 --- a/projects/packages/wp-build-polyfills/README.md +++ b/projects/packages/wp-build-polyfills/README.md @@ -63,6 +63,14 @@ The version threshold for force-replacements can be overridden with a third para WP_Build_Polyfills::register( 'my-plugin', array( 'wp-notices' ), '7.1' ); ``` +## Boot module asset proxy + +Packages that use `@wordpress/build` to generate pages get a hardcoded reference to `build/modules/boot/index.min.asset.php` in the generated page templates. This file provides the classic script dependencies and version hash needed to bootstrap the page. + +When `@wordpress/build` stops bundling the boot module (as planned in upcoming Gutenberg changes), this asset file will no longer be generated. The polyfills package ships a proxy file (`src/boot-module-asset-proxy.php`) that resolves the asset data from this package's build output at runtime, using `ReflectionClass` to locate the package regardless of install path (`vendor/` or `jetpack_vendor/`). + +The Jetpack monorepo CLI (`jetpack build`) automatically copies this proxy to the expected location (`build/modules/boot/index.min.asset.php`) after building any package that has `build/pages/` and depends on this package. No per-consumer configuration is needed. + ## Development ```bash From 6da6394438c8ba6a81c95245f4148110398a6244 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 22:09:12 -0300 Subject: [PATCH 31/40] Add tests for boot module asset proxy Co-Authored-By: Claude Opus 4.6 --- .../tests/php/WP_Build_Polyfills_Test.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 7636b5cb4956..aef4678334e8 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -496,6 +496,66 @@ public function test_get_consumers_tracks_polyfill_consumers() { $this->assertArrayNotHasKey( 'wp-private-apis', $consumers ); } + // ── Boot module asset proxy tests ───────────────────────────────────────── + + /** + * Test that the boot asset proxy returns the real asset data when the class + * is loaded and the build asset file exists. + */ + public function test_boot_asset_proxy_returns_real_asset_data() { + $package_root = dirname( __DIR__, 2 ); + $asset_file = $package_root . '/build/modules/boot/index.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + $this->markTestSkipped( 'Build asset file not present; run build first.' ); + } + + $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; + $asset = require $proxy_file; + + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertIsArray( $asset['dependencies'] ); + $this->assertNotEmpty( $asset['version'] ); + } + + /** + * Test that the boot asset proxy returns fallback data when the build + * asset file does not exist. + * + * Temporarily renames the real asset file, requires the proxy (which uses + * ReflectionClass to find the build dir), then restores the original. + */ + public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { + $package_root = dirname( __DIR__, 2 ); + $asset_file = $package_root . '/build/modules/boot/index.asset.php'; + $backup_file = $asset_file . '.bak'; + + // Skip if the build hasn't run (asset file doesn't exist). + if ( ! file_exists( $asset_file ) ) { + $this->markTestSkipped( 'Build asset file not present; run build first.' ); + } + + rename( $asset_file, $backup_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + + try { + // The proxy file uses `require` internally, but PHP caches the + // return value per file path. Use a fresh include via a wrapper + // to avoid the cache. + $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; + $asset = ( static function ( $file ) { + return require $file; + } )( $proxy_file ); + + $this->assertIsArray( $asset ); + $this->assertSame( array(), $asset['dependencies'] ); + $this->assertSame( '', $asset['version'] ); + } finally { + rename( $backup_file, $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + } + } + /** * Test that only requested polyfills are registered. */ From 3f71bf80518a3ce5e631ceb9b30558cee8096ee3 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 9 Mar 2026 22:43:23 -0300 Subject: [PATCH 32/40] Make boot asset proxy tests run without build artifacts and warn on missing proxy - Create fixture asset files in tests instead of skipping when build hasn't run, ensuring deterministic execution in all environments. - Emit a warning in provideBootAssetProxy() when build/pages exists but the proxy source is not found. Co-Authored-By: Claude Opus 4.6 --- .../tests/php/WP_Build_Polyfills_Test.php | 47 +++++++++++++------ tools/cli/commands/build.js | 6 +++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index aef4678334e8..76b611035ab6 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -505,19 +505,36 @@ public function test_get_consumers_tracks_polyfill_consumers() { public function test_boot_asset_proxy_returns_real_asset_data() { $package_root = dirname( __DIR__, 2 ); $asset_file = $package_root . '/build/modules/boot/index.asset.php'; + $created = false; + // Create a fixture asset file if the build hasn't run, so the test + // executes deterministically regardless of environment. if ( ! file_exists( $asset_file ) ) { - $this->markTestSkipped( 'Build asset file not present; run build first.' ); + $dir = dirname( $asset_file ); + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $asset_file, + " array( 'wp-i18n' ), 'version' => 'fixture-1.0' );\n" + ); + $created = true; } - $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; - $asset = require $proxy_file; + try { + $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; + $asset = require $proxy_file; - $this->assertIsArray( $asset ); - $this->assertArrayHasKey( 'dependencies', $asset ); - $this->assertArrayHasKey( 'version', $asset ); - $this->assertIsArray( $asset['dependencies'] ); - $this->assertNotEmpty( $asset['version'] ); + $this->assertIsArray( $asset ); + $this->assertArrayHasKey( 'dependencies', $asset ); + $this->assertArrayHasKey( 'version', $asset ); + $this->assertIsArray( $asset['dependencies'] ); + $this->assertNotEmpty( $asset['version'] ); + } finally { + if ( $created ) { + unlink( $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + } + } } /** @@ -531,14 +548,14 @@ public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { $package_root = dirname( __DIR__, 2 ); $asset_file = $package_root . '/build/modules/boot/index.asset.php'; $backup_file = $asset_file . '.bak'; + $backed_up = false; - // Skip if the build hasn't run (asset file doesn't exist). - if ( ! file_exists( $asset_file ) ) { - $this->markTestSkipped( 'Build asset file not present; run build first.' ); + // Ensure the asset file is absent — back it up if a build has run. + if ( file_exists( $asset_file ) ) { + rename( $asset_file, $backup_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + $backed_up = true; } - rename( $asset_file, $backup_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename - try { // The proxy file uses `require` internally, but PHP caches the // return value per file path. Use a fresh include via a wrapper @@ -552,7 +569,9 @@ public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { $this->assertSame( array(), $asset['dependencies'] ); $this->assertSame( '', $asset['version'] ); } finally { - rename( $backup_file, $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + if ( $backed_up ) { + rename( $backup_file, $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + } } } diff --git a/tools/cli/commands/build.js b/tools/cli/commands/build.js index bc1980ead595..cd7e18f7c5cf 100644 --- a/tools/cli/commands/build.js +++ b/tools/cli/commands/build.js @@ -637,6 +637,12 @@ async function provideBootAssetProxy( t ) { await Promise.all( candidates.map( f => fsExists( f ).then( ok => ok && f ) ) ) ).find( Boolean ); if ( ! proxySource ) { + await t.output( + chalk.yellow( + 'Warning: build/pages exists but wp-build-polyfills boot module asset proxy was not found.\n' + ) + + chalk.yellow( 'Ensure automattic/jetpack-wp-build-polyfills is installed via Composer.\n' ) + ); return; } From 1c60ad6e1dd5f458ba102bca26a295e542c575ad Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 10 Mar 2026 20:10:38 -0300 Subject: [PATCH 33/40] update wp-build --- pnpm-lock.yaml | 10 +++++----- projects/packages/forms/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d0c33327687..30ca2b24dcb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2598,8 +2598,8 @@ importers: specifier: 6.41.0 version: 6.41.0 '@wordpress/build': - specifier: 0.8.0 - version: 0.8.0(@babel/core@7.29.0)(@wordpress/boot@0.8.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(@wordpress/route@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(browserslist@4.28.1) + specifier: 0.10.1-next.v.202603102151.0 + version: 0.10.1-next.v.202603102151.0(@babel/core@7.29.0)(@wordpress/boot@0.8.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(@wordpress/route@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(browserslist@4.28.1) '@wordpress/date': specifier: 5.41.0 version: 5.41.0 @@ -10547,8 +10547,8 @@ packages: resolution: {integrity: sha512-Cp9JcjdL6ZYVNUGgXR/XOO9Fueb3i0dzpvdpC1pB/iJldiQWYRPZ7LMCaJrwprG92/AfuU68BaR5CwTwrSN5tA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} - '@wordpress/build@0.8.0': - resolution: {integrity: sha512-703tl7VoGLNtAAEdVzLVWAVkid6jMh6tGZgD1NNH/2mWy+FXmOv/m5NxeoD+FD6Je3d93ladA+ln83ZcPH7Mzw==} + '@wordpress/build@0.10.1-next.v.202603102151.0': + resolution: {integrity: sha512-uk7LIvouTnrMTgFnvdGvS/BhVjBkkL5fwnr8JCyRud4AObAzsJRixu+ZG//T6SNLdJuiLdF5zihMBKCEqVvN8g==} engines: {node: '>=20.10.0', npm: '>=10.2.3'} hasBin: true peerDependencies: @@ -24164,7 +24164,7 @@ snapshots: '@wordpress/browserslist-config@6.41.0': {} - '@wordpress/build@0.8.0(@babel/core@7.29.0)(@wordpress/boot@0.8.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(@wordpress/route@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(browserslist@4.28.1)': + '@wordpress/build@0.10.1-next.v.202603102151.0(@babel/core@7.29.0)(@wordpress/boot@0.8.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(@wordpress/route@0.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1))(browserslist@4.28.1)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.4.27(postcss@8.5.6) diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index c562b692d7ec..a1ec8492247c 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -112,7 +112,7 @@ "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/api-fetch": "7.41.0", "@wordpress/browserslist-config": "6.41.0", - "@wordpress/build": "0.8.0", + "@wordpress/build": "0.10.1-next.v.202603102151.0", "@wordpress/date": "5.41.0", "autoprefixer": "10.4.20", "browserslist": "^4.24.0", From 019f1bc434d92d54fca805d05a08a3df8832e541 Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 10 Mar 2026 20:17:41 -0300 Subject: [PATCH 34/40] remove unnecessary class_exists check --- .../forms/src/dashboard/class-dashboard.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 46d68d454f42..f4e0129a2fed 100644 --- a/projects/packages/forms/src/dashboard/class-dashboard.php +++ b/projects/packages/forms/src/dashboard/class-dashboard.php @@ -12,6 +12,7 @@ use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State; use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin; use Automattic\Jetpack\Tracking; +use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -41,15 +42,13 @@ public static function load_wp_build() { } // Register polyfills for WP < 7.0 (must run before build.php). - if ( class_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::class ) ) { - \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register( - 'jetpack-forms', - array_merge( - \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES, - \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS - ) - ); - } + WP_Build_Polyfills::register( + 'jetpack-forms', + array_merge( + WP_Build_Polyfills::SCRIPT_HANDLES, + WP_Build_Polyfills::MODULE_IDS + ) + ); $wp_build_index = dirname( __DIR__, 2 ) . '/build/build.php'; From 0a302ccf68dbba922b1cb4ebef44e77de139f8c0 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 13 Mar 2026 17:57:29 -0300 Subject: [PATCH 35/40] Improve wp-build-polyfills test setup and phpcs configuration Add tests/.phpcs.dir.xml with Jetpack-Tests ruleset to eliminate inline phpcs:ignore comments. Use secure atomic mkdir for temp directory creation to prevent symlink race conditions. Fix inaccurate docblock on request_polyfills helper. Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/tests/.phpcs.dir.xml | 4 + .../tests/php/WP_Build_Polyfills_Test.php | 94 +++++++++---------- 2 files changed, 49 insertions(+), 49 deletions(-) create mode 100644 projects/packages/wp-build-polyfills/tests/.phpcs.dir.xml diff --git a/projects/packages/wp-build-polyfills/tests/.phpcs.dir.xml b/projects/packages/wp-build-polyfills/tests/.phpcs.dir.xml new file mode 100644 index 000000000000..46951fe77b37 --- /dev/null +++ b/projects/packages/wp-build-polyfills/tests/.phpcs.dir.xml @@ -0,0 +1,4 @@ + + + + diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index 76b611035ab6..f91e0895cb8a 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -1,10 +1,4 @@ -build_dir = sys_get_temp_dir() . '/wp-build-polyfills-test-' . uniqid(); - mkdir( $this->build_dir . '/scripts/notices', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - mkdir( $this->build_dir . '/scripts/private-apis', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - mkdir( $this->build_dir . '/scripts/theme', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - mkdir( $this->build_dir . '/modules/boot', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - mkdir( $this->build_dir . '/modules/route', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - mkdir( $this->build_dir . '/modules/a11y', 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + $this->build_dir = null; + $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'wp-build-polyfills-test-'; + for ( $i = 0; $i < 1000; $i++ ) { + $tmpdir = $base . uniqid(); + // Atomic mkdir prevents symlink race (TOCTOU). + if ( @mkdir( $tmpdir, 0700 ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $this->build_dir = $tmpdir; + break; + } + } + if ( null === $this->build_dir ) { + throw new \RuntimeException( 'Failed to create temporary directory' ); + } + + mkdir( $this->build_dir . '/scripts/notices', 0755, true ); + mkdir( $this->build_dir . '/scripts/private-apis', 0755, true ); + mkdir( $this->build_dir . '/scripts/theme', 0755, true ); + mkdir( $this->build_dir . '/modules/boot', 0755, true ); + mkdir( $this->build_dir . '/modules/route', 0755, true ); + mkdir( $this->build_dir . '/modules/a11y', 0755, true ); $this->original_wp_version = $GLOBALS['wp_version']; $this->original_wp_script_modules = $GLOBALS['wp_script_modules'] ?? null; @@ -66,13 +74,11 @@ public function set_up() { */ #[After] public function tear_down() { - $GLOBALS['wp_version'] = $this->original_wp_version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - + $GLOBALS['wp_version'] = $this->original_wp_version; if ( null === $this->original_wp_script_modules ) { unset( $GLOBALS['wp_script_modules'] ); } else { - $GLOBALS['wp_script_modules'] = $this->original_wp_script_modules; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - } + $GLOBALS['wp_script_modules'] = $this->original_wp_script_modules; } // Reset static state. $requested = new \ReflectionProperty( WP_Build_Polyfills::class, 'requested' ); @@ -114,9 +120,8 @@ private function create_asset_file( $path, $deps = array(), $version = '1.0.0', ), $extra ); - $contents = 'build_dir . '/' . $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - } + $contents = 'build_dir . '/' . $path, $contents ); } /** * Create a WP_Scripts instance with polyfill handles removed. @@ -137,9 +142,9 @@ private function create_clean_scripts() { /** * Request all available polyfills for a test consumer. * - * Populates the static $requested property so register_scripts/register_modules - * will process all handles. Uses register() but prevents the hook from firing - * by resetting $hooked afterwards. + * Populates the static $requested property via reflection so + * register_scripts/register_modules will process all handles, + * without triggering the wp_default_scripts hook. * * @param string[] $polyfills Optional specific polyfills to request. Defaults to all. */ @@ -242,13 +247,10 @@ private function recursive_rmdir( $dir ) { ); foreach ( $items as $item ) { if ( $item->isDir() ) { - rmdir( $item->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir - } else { - unlink( $item->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink - } + rmdir( $item->getRealPath() ); } else { + unlink( $item->getRealPath() ); } } - rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir - } + rmdir( $dir ); } /** * Test that all scripts are registered when all asset files exist. @@ -313,7 +315,7 @@ public function test_register_scripts_skips_wp_theme_when_already_registered() { * Test that wp-notices is force-replaced on WP < 7.0. */ public function test_register_scripts_force_replaces_wp_notices_on_old_wp() { - $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_version'] = '6.8'; $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); $scripts = $this->create_clean_scripts(); @@ -330,7 +332,7 @@ public function test_register_scripts_force_replaces_wp_notices_on_old_wp() { * Test that wp-private-apis is force-replaced on WP < 7.0. */ public function test_register_scripts_force_replaces_wp_private_apis_on_old_wp() { - $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_version'] = '6.8'; $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '9.9.9' ); $scripts = $this->create_clean_scripts(); @@ -347,7 +349,7 @@ public function test_register_scripts_force_replaces_wp_private_apis_on_old_wp() * Test that neither wp-notices nor wp-private-apis is force-replaced on WP >= 7.0. */ public function test_register_scripts_does_not_force_replace_on_wp_7() { - $GLOBALS['wp_version'] = '7.0'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_version'] = '7.0'; $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '9.9.9' ); @@ -368,7 +370,7 @@ public function test_register_scripts_does_not_force_replace_on_wp_7() { * Test that force scripts register fine even when not pre-existing. */ public function test_register_scripts_force_registers_fresh_on_old_wp() { - $GLOBALS['wp_version'] = '6.8'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $GLOBALS['wp_version'] = '6.8'; $this->create_asset_file( 'scripts/notices/index.asset.php', array(), '9.9.9' ); $this->create_asset_file( 'scripts/private-apis/index.asset.php', array(), '8.8.8' ); @@ -403,8 +405,7 @@ public function test_register_scripts_has_correct_dependencies() { */ public function test_register_modules_registers_all_when_asset_files_exist() { // Reset the script modules global so we start fresh. - $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); $this->create_asset_file( 'modules/boot/index.asset.php', array(), @@ -435,8 +436,7 @@ public function test_register_modules_registers_all_when_asset_files_exist() { * Test that no modules are registered when asset files are missing. */ public function test_register_modules_skips_when_asset_files_missing() { - $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); $this->invoke_register_modules(); $this->assertFalse( $this->is_module_registered( '@wordpress/boot' ) ); @@ -448,8 +448,7 @@ public function test_register_modules_skips_when_asset_files_missing() { * Test that pre-registered modules are not replaced (first-wins semantics). */ public function test_register_modules_does_not_replace_existing() { - $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - + $GLOBALS['wp_script_modules'] = new \WP_Script_Modules(); // Pre-register @wordpress/boot. wp_register_script_module( '@wordpress/boot', 'https://example.com/core-boot.js', array(), '1.0.0-core' ); @@ -512,9 +511,8 @@ public function test_boot_asset_proxy_returns_real_asset_data() { if ( ! file_exists( $asset_file ) ) { $dir = dirname( $asset_file ); if ( ! is_dir( $dir ) ) { - mkdir( $dir, 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - } - file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + mkdir( $dir, 0755, true ); } + file_put_contents( $asset_file, " array( 'wp-i18n' ), 'version' => 'fixture-1.0' );\n" ); @@ -532,8 +530,7 @@ public function test_boot_asset_proxy_returns_real_asset_data() { $this->assertNotEmpty( $asset['version'] ); } finally { if ( $created ) { - unlink( $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink - } + unlink( $asset_file ); } } } @@ -552,7 +549,7 @@ public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { // Ensure the asset file is absent — back it up if a build has run. if ( file_exists( $asset_file ) ) { - rename( $asset_file, $backup_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename + rename( $asset_file, $backup_file ); $backed_up = true; } @@ -570,8 +567,7 @@ public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { $this->assertSame( '', $asset['version'] ); } finally { if ( $backed_up ) { - rename( $backup_file, $asset_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename - } + rename( $backup_file, $asset_file ); } } } From efc2852baab5353ff341bf35de2133c94eec743c Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 13 Mar 2026 17:59:25 -0300 Subject: [PATCH 36/40] Replace duplicate phpunit configs with symlinks phpunit.12.xml.dist is identical to phpunit.11.xml.dist, and phpunit.8.xml.dist is identical to phpunit.9.xml.dist, matching the convention used elsewhere in the monorepo. Co-Authored-By: Claude Opus 4.6 --- .../wp-build-polyfills/phpunit.12.xml.dist | 37 +------------------ .../wp-build-polyfills/phpunit.8.xml.dist | 18 +-------- 2 files changed, 2 insertions(+), 53 deletions(-) mode change 100644 => 120000 projects/packages/wp-build-polyfills/phpunit.12.xml.dist mode change 100644 => 120000 projects/packages/wp-build-polyfills/phpunit.8.xml.dist diff --git a/projects/packages/wp-build-polyfills/phpunit.12.xml.dist b/projects/packages/wp-build-polyfills/phpunit.12.xml.dist deleted file mode 100644 index f7418373829b..000000000000 --- a/projects/packages/wp-build-polyfills/phpunit.12.xml.dist +++ /dev/null @@ -1,36 +0,0 @@ - - - - - tests/php - - - - - - - - src - - - - - diff --git a/projects/packages/wp-build-polyfills/phpunit.12.xml.dist b/projects/packages/wp-build-polyfills/phpunit.12.xml.dist new file mode 120000 index 000000000000..9fdb7a2c745c --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.12.xml.dist @@ -0,0 +1 @@ +phpunit.11.xml.dist \ No newline at end of file diff --git a/projects/packages/wp-build-polyfills/phpunit.8.xml.dist b/projects/packages/wp-build-polyfills/phpunit.8.xml.dist deleted file mode 100644 index 3965963c485e..000000000000 --- a/projects/packages/wp-build-polyfills/phpunit.8.xml.dist +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests/php - - - diff --git a/projects/packages/wp-build-polyfills/phpunit.8.xml.dist b/projects/packages/wp-build-polyfills/phpunit.8.xml.dist new file mode 120000 index 000000000000..707bde67863c --- /dev/null +++ b/projects/packages/wp-build-polyfills/phpunit.8.xml.dist @@ -0,0 +1 @@ +phpunit.9.xml.dist \ No newline at end of file From fd38c8b19fb80003907af694958a178e0af50109 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 13 Mar 2026 18:00:52 -0300 Subject: [PATCH 37/40] Fix misleading i18n comment in wp-build-polyfills webpack config The i18n plugins are disabled because Core doesn't provide translations for these packages (they aren't shipped with Core), not because @wordpress/i18n is external. Co-Authored-By: Claude Opus 4.6 --- projects/packages/wp-build-polyfills/webpack.config.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/packages/wp-build-polyfills/webpack.config.js b/projects/packages/wp-build-polyfills/webpack.config.js index e53db9dff82f..f062d9946237 100644 --- a/projects/packages/wp-build-polyfills/webpack.config.js +++ b/projects/packages/wp-build-polyfills/webpack.config.js @@ -114,8 +114,9 @@ const sharedConfig = { }, }; -// Plugins disabled for all polyfill builds: no CSS is bundled, and i18n is -// handled by core's textdomain since @wordpress/i18n remains external. +// Plugins disabled for all polyfill builds: no CSS is bundled, and the i18n +// loader/checker are unnecessary since Core doesn't provide translations for +// these packages (they aren't shipped with Core in the first place). const disabledPlugins = { MiniCssExtractPlugin: false, MiniCssWithRtlPlugin: false, From 7e302d91a02fbbe20d7fce5800e9d3341bb23d46 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 13 Mar 2026 18:15:50 -0300 Subject: [PATCH 38/40] Move boot asset file copy from CLI to forms build script Instead of a runtime proxy that resolves asset data via class autoloading, copy the built asset file directly at build time. Add a Node.js bin script (provide-boot-asset-file) in wp-build-polyfills that consumers call after wp-build. Remove boot-module-asset-proxy.php and the load_boot_module_asset_file() static method as they are no longer needed. Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 3 + projects/packages/forms/package.json | 4 +- .../packages/wp-build-polyfills/README.md | 10 ++- .../bin/provide-boot-asset-file.js | 28 +++++++ .../packages/wp-build-polyfills/package.json | 3 + .../src/boot-module-asset-proxy.php | 35 --------- .../tests/php/WP_Build_Polyfills_Test.php | 76 ------------------- tools/cli/commands/build.js | 59 -------------- 8 files changed, 44 insertions(+), 174 deletions(-) create mode 100755 projects/packages/wp-build-polyfills/bin/provide-boot-asset-file.js delete mode 100644 projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30ca2b24dcb5..2e3ff0d7f2ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2558,6 +2558,9 @@ importers: '@automattic/jetpack-webpack-config': specifier: workspace:* version: link:../../js-packages/webpack-config + '@automattic/jetpack-wp-build-polyfills': + specifier: workspace:* + version: link:../wp-build-polyfills '@automattic/remove-asset-webpack-plugin': specifier: workspace:* version: link:../../js-packages/remove-asset-webpack-plugin diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index a1ec8492247c..954c0bc20956 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -16,8 +16,9 @@ "author": "Automattic", "type": "module", "scripts": { - "build": "pnpm run clean && pnpm run build:blocks && pnpm run build:contact-form && pnpm run build:dashboard && pnpm run build:wp-build && pnpm run build:form-editor && pnpm run module:build", + "build": "pnpm run clean && pnpm run build:blocks && pnpm run build:contact-form && pnpm run build:dashboard && pnpm run build:wp-build && pnpm run build:boot-asset && pnpm run build:form-editor && pnpm run module:build", "build:blocks": "webpack --config ./tools/webpack.config.blocks.js", + "build:boot-asset": "provide-boot-asset-file", "build:contact-form": "webpack --config ./tools/webpack.config.contact-form.js", "build:dashboard": "webpack --config ./tools/webpack.config.dashboard.js", "build:form-editor": "webpack --config ./tools/webpack.config.form-editor.js", @@ -99,6 +100,7 @@ "@automattic/color-studio": "4.1.0", "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-webpack-config": "workspace:*", + "@automattic/jetpack-wp-build-polyfills": "workspace:*", "@automattic/remove-asset-webpack-plugin": "workspace:*", "@babel/core": "7.29.0", "@babel/runtime": "7.28.6", diff --git a/projects/packages/wp-build-polyfills/README.md b/projects/packages/wp-build-polyfills/README.md index f3dbdde56afc..58b029ce19b2 100644 --- a/projects/packages/wp-build-polyfills/README.md +++ b/projects/packages/wp-build-polyfills/README.md @@ -63,13 +63,17 @@ The version threshold for force-replacements can be overridden with a third para WP_Build_Polyfills::register( 'my-plugin', array( 'wp-notices' ), '7.1' ); ``` -## Boot module asset proxy +## Boot module asset file Packages that use `@wordpress/build` to generate pages get a hardcoded reference to `build/modules/boot/index.min.asset.php` in the generated page templates. This file provides the classic script dependencies and version hash needed to bootstrap the page. -When `@wordpress/build` stops bundling the boot module (as planned in upcoming Gutenberg changes), this asset file will no longer be generated. The polyfills package ships a proxy file (`src/boot-module-asset-proxy.php`) that resolves the asset data from this package's build output at runtime, using `ReflectionClass` to locate the package regardless of install path (`vendor/` or `jetpack_vendor/`). +When `@wordpress/build` stops bundling the boot module (as planned in upcoming Gutenberg changes), this asset file will no longer be generated. This package builds its own boot module asset file, and ships a bin script (`provide-boot-asset-file`) that copies it to the expected location in the consumer's build directory. -The Jetpack monorepo CLI (`jetpack build`) automatically copies this proxy to the expected location (`build/modules/boot/index.min.asset.php`) after building any package that has `build/pages/` and depends on this package. No per-consumer configuration is needed. +Consuming packages should add `@automattic/jetpack-wp-build-polyfills` as a devDependency and call the script after `wp-build`: + +```json +"build:boot-proxy": "provide-boot-asset-file" +``` ## Development diff --git a/projects/packages/wp-build-polyfills/bin/provide-boot-asset-file.js b/projects/packages/wp-build-polyfills/bin/provide-boot-asset-file.js new file mode 100755 index 000000000000..349b04a601a4 --- /dev/null +++ b/projects/packages/wp-build-polyfills/bin/provide-boot-asset-file.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/* global __dirname, process */ + +/** + * Copy the boot module asset file into the consumer's build directory. + * + * When wp-build generates page templates, they reference a boot module asset + * file at build/modules/boot/index.min.asset.php. This script copies the + * built asset file from this package to that expected location. + * + * Run this from the consuming package's root directory after wp-build. + */ + +const { cpSync, existsSync, mkdirSync } = require( 'fs' ); +const path = require( 'path' ); + +const assetSource = path.join( __dirname, '..', 'build', 'modules', 'boot', 'index.asset.php' ); +const targetDir = path.join( process.cwd(), 'build', 'modules', 'boot' ); +const targetFile = path.join( targetDir, 'index.min.asset.php' ); + +if ( ! existsSync( assetSource ) ) { + throw new Error( + `Boot module asset file not found at ${ assetSource }. Ensure wp-build-polyfills has been built first.` + ); +} + +mkdirSync( targetDir, { recursive: true } ); +cpSync( assetSource, targetFile ); diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index 8dc8ccbc806b..cadcb3bdacf3 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -11,6 +11,9 @@ }, "license": "GPL-2.0-or-later", "author": "Automattic", + "bin": { + "provide-boot-asset-file": "bin/provide-boot-asset-file.js" + }, "scripts": { "build": "pnpm run clean && webpack", "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build", diff --git a/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php b/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php deleted file mode 100644 index 755c60c2d7a2..000000000000 --- a/projects/packages/wp-build-polyfills/src/boot-module-asset-proxy.php +++ /dev/null @@ -1,35 +0,0 @@ - array(), - 'version' => '', - ); -} - -$ref = new ReflectionClass( $class_name ); -$asset_file = dirname( $ref->getFileName(), 2 ) . '/build/modules/boot/index.asset.php'; - -if ( ! file_exists( $asset_file ) ) { - return array( - 'dependencies' => array(), - 'version' => '', - ); -} - -return require $asset_file; diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index f91e0895cb8a..1d71d6902da1 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -495,82 +495,6 @@ public function test_get_consumers_tracks_polyfill_consumers() { $this->assertArrayNotHasKey( 'wp-private-apis', $consumers ); } - // ── Boot module asset proxy tests ───────────────────────────────────────── - - /** - * Test that the boot asset proxy returns the real asset data when the class - * is loaded and the build asset file exists. - */ - public function test_boot_asset_proxy_returns_real_asset_data() { - $package_root = dirname( __DIR__, 2 ); - $asset_file = $package_root . '/build/modules/boot/index.asset.php'; - $created = false; - - // Create a fixture asset file if the build hasn't run, so the test - // executes deterministically regardless of environment. - if ( ! file_exists( $asset_file ) ) { - $dir = dirname( $asset_file ); - if ( ! is_dir( $dir ) ) { - mkdir( $dir, 0755, true ); } - file_put_contents( - $asset_file, - " array( 'wp-i18n' ), 'version' => 'fixture-1.0' );\n" - ); - $created = true; - } - - try { - $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; - $asset = require $proxy_file; - - $this->assertIsArray( $asset ); - $this->assertArrayHasKey( 'dependencies', $asset ); - $this->assertArrayHasKey( 'version', $asset ); - $this->assertIsArray( $asset['dependencies'] ); - $this->assertNotEmpty( $asset['version'] ); - } finally { - if ( $created ) { - unlink( $asset_file ); } - } - } - - /** - * Test that the boot asset proxy returns fallback data when the build - * asset file does not exist. - * - * Temporarily renames the real asset file, requires the proxy (which uses - * ReflectionClass to find the build dir), then restores the original. - */ - public function test_boot_asset_proxy_returns_fallback_when_asset_missing() { - $package_root = dirname( __DIR__, 2 ); - $asset_file = $package_root . '/build/modules/boot/index.asset.php'; - $backup_file = $asset_file . '.bak'; - $backed_up = false; - - // Ensure the asset file is absent — back it up if a build has run. - if ( file_exists( $asset_file ) ) { - rename( $asset_file, $backup_file ); - $backed_up = true; - } - - try { - // The proxy file uses `require` internally, but PHP caches the - // return value per file path. Use a fresh include via a wrapper - // to avoid the cache. - $proxy_file = $package_root . '/src/boot-module-asset-proxy.php'; - $asset = ( static function ( $file ) { - return require $file; - } )( $proxy_file ); - - $this->assertIsArray( $asset ); - $this->assertSame( array(), $asset['dependencies'] ); - $this->assertSame( '', $asset['version'] ); - } finally { - if ( $backed_up ) { - rename( $backup_file, $asset_file ); } - } - } - /** * Test that only requested polyfills are registered. */ diff --git a/tools/cli/commands/build.js b/tools/cli/commands/build.js index cd7e18f7c5cf..b29022222851 100644 --- a/tools/cli/commands/build.js +++ b/tools/cli/commands/build.js @@ -595,62 +595,6 @@ async function checkCollisions( basedir ) { return [ ...collisions ]; } -/** - * Provide the boot module asset proxy for packages using wp-build pages. - * - * When wp-build generates page templates, they reference a boot module asset - * file at build/modules/boot/index.min.asset.php. If the package depends on - * jetpack-wp-build-polyfills and has wp-build pages, this function copies the - * proxy file from the polyfills package to the expected location. - * - * @param {object} t - Task object. - */ -async function provideBootAssetProxy( t ) { - const pagesDir = npath.join( t.cwd, 'build', 'pages' ); - const targetFile = npath.join( t.cwd, 'build', 'modules', 'boot', 'index.min.asset.php' ); - - // Only act if the package has wp-build pages and the file is missing. - if ( ! ( await fsExists( pagesDir ) ) || ( await fsExists( targetFile ) ) ) { - return; - } - - // Look for the proxy in jetpack_vendor (production) or vendor (dev). - const candidates = [ - npath.join( - t.cwd, - 'jetpack_vendor', - 'automattic', - 'jetpack-wp-build-polyfills', - 'src', - 'boot-module-asset-proxy.php' - ), - npath.join( - t.cwd, - 'vendor', - 'automattic', - 'jetpack-wp-build-polyfills', - 'src', - 'boot-module-asset-proxy.php' - ), - ]; - const proxySource = ( - await Promise.all( candidates.map( f => fsExists( f ).then( ok => ok && f ) ) ) - ).find( Boolean ); - if ( ! proxySource ) { - await t.output( - chalk.yellow( - 'Warning: build/pages exists but wp-build-polyfills boot module asset proxy was not found.\n' - ) + - chalk.yellow( 'Ensure automattic/jetpack-wp-build-polyfills is installed via Composer.\n' ) - ); - return; - } - - await fs.mkdir( npath.dirname( targetFile ), { recursive: true } ); - await fs.copyFile( proxySource, targetFile ); - await t.output( 'Provided boot module asset proxy from wp-build-polyfills\n' ); -} - /** * Build a project. * @@ -852,9 +796,6 @@ async function buildProject( t ) { } ); } - // Provide the boot module asset proxy for packages using wp-build pages. - await provideBootAssetProxy( t ); - // If we're not mirroring, the build is done. Mirroring has a bunch of stuff to do yet. if ( ! t.argv.forMirrors ) { return; From 10a0ba1e0cb2adecb7d30ccf7f550f5b315ef086 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 13 Mar 2026 19:29:59 -0300 Subject: [PATCH 39/40] fix build test --- projects/plugins/jetpack/tests/e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/tests/e2e/package.json b/projects/plugins/jetpack/tests/e2e/package.json index fcef5bb28b3c..78e9b02e90d2 100644 --- a/projects/plugins/jetpack/tests/e2e/package.json +++ b/projects/plugins/jetpack/tests/e2e/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "allure-report": "allure generate --clean --output ./output/allure-report ./output/allure-results && allure open ./output/allure-report", - "build": "pnpm jetpack build packages/my-jetpack js-packages/social-logos js-packages/boost-score-api js-packages/components js-packages/number-formatters packages/forms packages/assets packages/connection packages/publicize packages/blaze plugins/jetpack -v --no-pnpm-install --production", + "build": "pnpm jetpack build packages/my-jetpack js-packages/social-logos js-packages/boost-score-api js-packages/components js-packages/number-formatters packages/wp-build-polyfills packages/forms packages/assets packages/connection packages/publicize packages/blaze plugins/jetpack -v --no-pnpm-install --production", "clean": "rm -rf output", "config:decrypt": "pnpm test-decrypt-default-config && pnpm test-decrypt-config", "distclean": "rm -rf node_modules", From 2d5f4c69e81b8773a33a36ed3344b9a2ed97c424 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 16 Mar 2026 17:59:15 -0300 Subject: [PATCH 40/40] fix test --- projects/packages/forms/tests/php/dashboard/Dashboard_Test.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php index 1238531b1528..b95dfdcbdb5e 100644 --- a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php +++ b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php @@ -24,7 +24,7 @@ class Dashboard_Test extends BaseTestCase { */ public function tear_down() { $this->reset_wp_build_polyfills(); - unset( $_GET['page'] ); + unset( $_GET['page'], $_GET['p'] ); parent::tear_down(); } @@ -162,6 +162,7 @@ private function reset_wp_build_polyfills() { */ public function test_load_wp_build_registers_polyfills_on_wpbuild_page() { $_GET['page'] = Dashboard::FORMS_WPBUILD_ADMIN_SLUG; + $_GET['p'] = '/responses/inbox'; Dashboard::load_wp_build();