diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fb21298bc3..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 @@ -2598,8 +2601,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 @@ -4166,6 +4169,52 @@ 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/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) + '@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: @@ -10483,6 +10532,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'} @@ -10494,8 +10550,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: @@ -10837,6 +10893,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'} @@ -10886,6 +10948,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'} @@ -24024,6 +24097,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 @@ -24060,7 +24167,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) @@ -25322,6 +25429,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 @@ -25742,6 +25872,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 @@ -25822,6 +25961,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/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/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..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", @@ -112,7 +114,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", diff --git a/projects/packages/forms/src/dashboard/class-dashboard.php b/projects/packages/forms/src/dashboard/class-dashboard.php index 1200ca620141..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 ); @@ -36,9 +37,19 @@ 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). + 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'; if ( file_exists( $wp_build_index ) ) { diff --git a/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php b/projects/packages/forms/tests/php/dashboard/Dashboard_Test.php index d1d740927164..b95dfdcbdb5e 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; @@ -18,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'], $_GET['p'] ); + parent::tear_down(); + } + /** * Test get_forms_admin_url without tab parameter */ @@ -122,6 +132,91 @@ 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' ); + 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' ); + } + + /** + * 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; + $_GET['p'] = '/responses/inbox'; + + Dashboard::load_wp_build(); + + $ref = new \ReflectionClass( WP_Build_Polyfills::class ); + $requested = $ref->getProperty( 'requested' ); + 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 ); + + 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'." ); + } + } + + /** + * 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' ); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $value = $requested->getValue(); + + $this->assertEmpty( $value, 'No polyfills should be registered when on a different 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' ); + if ( PHP_VERSION_ID < 80100 ) { + $requested->setAccessible( true ); + } + $value = $requested->getValue(); + + $this->assertEmpty( $value, 'No polyfills should be registered when no page is set.' ); + } + /** * Test is_jetpack_forms_admin_page when get_current_screen is not available */ diff --git a/projects/packages/wp-build-polyfills/.gitattributes b/projects/packages/wp-build-polyfills/.gitattributes new file mode 100644 index 000000000000..a8a09c7553b9 --- /dev/null +++ b/projects/packages/wp-build-polyfills/.gitattributes @@ -0,0 +1,21 @@ +# 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 +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 +webpack.config.js production-exclude +changelog/** production-exclude +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/.gitignore b/projects/packages/wp-build-polyfills/.gitignore new file mode 100644 index 000000000000..83a5ab2f4c63 --- /dev/null +++ b/projects/packages/wp-build-polyfills/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/build +/.cache 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 @@ +=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "scripts": { + "build-production": [ + "pnpm run build-production" + ], + "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": { + "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..cadcb3bdacf3 --- /dev/null +++ b/projects/packages/wp-build-polyfills/package.json @@ -0,0 +1,40 @@ +{ + "name": "@automattic/jetpack-wp-build-polyfills", + "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", + "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": { + "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", + "clean": "rm -rf build/" + }, + "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", + "@wordpress/theme": "^0.7.0", + "webpack": "^5.104.1", + "webpack-cli": "^6.0.1" + }, + "optionalDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + } +} 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 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 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 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/preserve-dynamic-imports-loader.js b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js new file mode 100644 index 000000000000..16094da8281a --- /dev/null +++ b/projects/packages/wp-build-polyfills/preserve-dynamic-imports-loader.js @@ -0,0 +1,47 @@ +/** + * 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. 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\(/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 */ '; + } ); +}; 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..f8ac3e88a1c4 --- /dev/null +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -0,0 +1,218 @@ + + */ + private static $requested = array(); + + /** + * Whether the wp_default_scripts hook has already been added. + * + * @var bool + */ + 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. + * @param string $wp_version_threshold The WordPress version below which force-replacements + * are applied. Defaults to '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 ( version_compare( $wp_version_threshold, self::$wp_version_threshold, '>' ) ) { + self::$wp_version_threshold = $wp_version_threshold; + } + + if ( self::$hooked ) { + return; + } + self::$hooked = true; + + $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, self::$wp_version_threshold ); + self::register_modules( $build_dir, $base_file ); + }, + 20 + ); + } + + /** + * 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. + * + * @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, $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 older WP: older Core versions ship + // notices without SnackbarNotices and InlineNotices component + // exports that @wordpress/boot depends on. + 'force' => $force_replace, + ), + 'wp-private-apis' => array( + 'path' => 'private-apis', + // 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' => $force_replace, + ), + 'wp-theme' => array( + 'path' => 'theme', + ), + ); + + 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 ) ) { + 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/scripts/' . $data['path'] . '/index.js', $base_file ), + $asset['dependencies'], + $asset['version'] + ); + } + } + + /** + * 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 $build_dir Absolute path to the build directory. + * @param string $base_file File path for plugins_url() computation. + */ + private static function register_modules( $build_dir, $base_file ) { + if ( ! function_exists( 'wp_register_script_module' ) ) { + return; + } + + $modules = array( 'boot', 'route', 'a11y' ); + + foreach ( $modules as $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 ) ) { + continue; + } + + $asset = require $asset_file; + + wp_register_script_module( + $module_id, + plugins_url( 'build/modules/' . $name . '/index.js', $base_file ), + $asset['module_dependencies'] ?? array(), + $asset['version'] + ); + } + } +} 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 new file mode 100644 index 000000000000..1d71d6902da1 --- /dev/null +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -0,0 +1,521 @@ +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; + } + + /** + * Tear down test fixtures. + * + * @after + */ + #[After] + public function tear_down() { + $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; } + + // 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 ); + + $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(); + } + + /** + * 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 ); } + + /** + * 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; + } + + /** + * Request all available polyfills for a test consumer. + * + * 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. + */ + 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(); + 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 ); + } + $method->invoke( null, $scripts, $this->build_dir, __FILE__, '7.0' ); + } + + /** + * 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 ); + } + $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' ); + if ( PHP_VERSION_ID < 80100 ) { + $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() ); } else { + unlink( $item->getRealPath() ); } + } + rmdir( $dir ); } + + /** + * 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'; + $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'; + $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'; + $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'; + $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(); + $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(); + $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(); + // 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( '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' ) ); + } +} 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 @@ + 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 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, + 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(); + + // 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 } ) => { + // 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 + // 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. + 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 ) ) { + const kind = dynamicImportRequests.has( request ) ? 'dynamic' : 'static'; + if ( kind === 'static' || ! moduleDeps.has( request ) ) { + moduleDeps.set( request, kind ); + } + return callback( null, `import ${ request }` ); + } + + // 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 ) }` ); + } + + // 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, + additionalAssets: true, + }, + () => { + 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, + 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 ), + }, + 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 ]; 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. diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 3c7b6c365888..b13b6b550ac0 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,65 @@ "relative": true } }, + { + "name": "automattic/jetpack-wp-build-polyfills", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/wp-build-polyfills", + "reference": "e8095b10b4cb4eac8b26dce82529f158aa5346e0" + }, + "require": { + "php": ">=7.2" + }, + "require-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": { + "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" + ], + "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": [ + "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", 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",