Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
90 / 99
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WP_Build_Polyfills
90.91% covered (success)
90.91%
90 / 99
60.00% covered (warning)
60.00%
3 / 5
31.72
0.00% covered (danger)
0.00%
0 / 1
 register
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
10.41
 get_consumers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_scripts
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
13
 is_gutenberg_version_safe
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_modules
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
1<?php
2/**
3 * Polyfill registration for Core packages not available or incomplete in older WordPress versions.
4 *
5 * Conditionally registers wp-notices, wp-private-apis, wp-theme (classic scripts) and
6 * `@wordpress/boot`, `@wordpress/route`, `@wordpress/a11y` (script modules)
7 * ONLY when they are not already provided by Core or Gutenberg.
8 *
9 * @package automattic/jetpack-wp-build-polyfills
10 */
11
12namespace Automattic\Jetpack\WP_Build_Polyfills;
13
14/**
15 * Registers polyfill scripts and modules for WordPress Core packages.
16 */
17class WP_Build_Polyfills {
18
19    /**
20     * Available polyfill handles for classic scripts.
21     */
22    const SCRIPT_HANDLES = array( 'wp-notices', 'wp-private-apis', 'wp-theme', 'wp-views' );
23
24    /**
25     * Available polyfill module IDs.
26     */
27    const MODULE_IDS = array( '@wordpress/boot', '@wordpress/route', '@wordpress/a11y' );
28
29    /**
30     * Minimum Gutenberg plugin version known to ship a private-apis allowlist
31     * that includes the dashboard packages used by this package's current build.
32     */
33    const GUTENBERG_PRIVATE_APIS_MIN_VERSION = '23.5.0';
34
35    /**
36     * Tracks which polyfills have been requested and by which consumers.
37     *
38     * Keys are polyfill handles/module IDs, values are arrays of consumer names.
39     *
40     * @var array<string, string[]>
41     */
42    private static $requested = array();
43
44    /**
45     * Whether the wp_default_scripts hook has already been added.
46     *
47     * @var bool
48     */
49    private static $hooked = false;
50
51    /**
52     * The WordPress version below which force-replacements are applied.
53     * When multiple consumers call register() with different thresholds,
54     * the highest threshold wins (most conservative approach).
55     *
56     * @var string
57     */
58    private static $wp_version_threshold = '7.0';
59
60    /**
61     * Register polyfill scripts and modules.
62     *
63     * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts
64     * at priority 20 so Core (default) and Gutenberg (priority 10) register first.
65     *
66     * When multiple consumers call this method with different thresholds, the
67     * highest threshold wins (most conservative — polyfills active on more versions).
68     *
69     * @param string   $consumer             A unique identifier for the consumer (e.g. plugin slug).
70     * @param string[] $polyfills             List of polyfill handles/module IDs to register.
71     *                                        Use class constants SCRIPT_HANDLES and MODULE_IDS for reference.
72     * @param string   $wp_version_threshold  The WordPress version below which force-replacements
73     *                                        are applied. Defaults to '7.0'.
74     */
75    public static function register( $consumer, $polyfills, $wp_version_threshold = '7.0' ) {
76        foreach ( $polyfills as $handle ) {
77            if ( ! in_array( $handle, self::SCRIPT_HANDLES, true ) && ! in_array( $handle, self::MODULE_IDS, true ) ) {
78                continue;
79            }
80            if ( ! isset( self::$requested[ $handle ] ) ) {
81                self::$requested[ $handle ] = array();
82            }
83            if ( ! in_array( $consumer, self::$requested[ $handle ], true ) ) {
84                self::$requested[ $handle ][] = $consumer;
85            }
86        }
87
88        if ( version_compare( $wp_version_threshold, self::$wp_version_threshold, '>' ) ) {
89            self::$wp_version_threshold = $wp_version_threshold;
90        }
91
92        if ( self::$hooked ) {
93            return;
94        }
95        self::$hooked = true;
96
97        $package_root = dirname( __DIR__ );
98        $build_dir    = $package_root . '/build';
99        $base_file    = $package_root . '/composer.json';
100
101        // `wp_default_scripts` fires once when the WP_Scripts singleton is
102        // instantiated. If something has already initialized `wp_scripts()` —
103        // common on admin requests where WP or other plugins register scripts
104        // before `admin_menu` priority 1 runs — adding this hook here is too
105        // late and the polyfills never register. Detect that case and run the
106        // registration synchronously so consumers can rely on the script
107        // handles and module IDs being available regardless of init order.
108        if ( did_action( 'wp_default_scripts' ) ) {
109            self::register_scripts( wp_scripts(), $build_dir, $base_file, self::$wp_version_threshold );
110            self::register_modules( $build_dir, $base_file );
111            return;
112        }
113
114        add_action(
115            'wp_default_scripts',
116            function ( $scripts ) use ( $build_dir, $base_file ) {
117                self::register_scripts( $scripts, $build_dir, $base_file, self::$wp_version_threshold );
118                self::register_modules( $build_dir, $base_file );
119            },
120            20
121        );
122    }
123
124    /**
125     * Get the map of requested polyfills and their consumers.
126     *
127     * @return array<string, string[]> Keys are polyfill handles/module IDs, values are consumer names.
128     */
129    public static function get_consumers() {
130        return self::$requested;
131    }
132
133    /**
134     * Register polyfill classic scripts.
135     *
136     * @param \WP_Scripts $scripts               The WP_Scripts instance.
137     * @param string      $build_dir             Absolute path to the build directory.
138     * @param string      $base_file             File path for plugins_url() computation.
139     * @param string      $wp_version_threshold  WP version below which force-replacements apply.
140     */
141    private static function register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ) {
142        // Force-replace only when Core's bundled scripts are incomplete and
143        // Gutenberg cannot be trusted to provide a compatible implementation.
144        $gutenberg_version = defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null;
145
146        $polyfills = array(
147            'wp-notices'      => array(
148                'path'            => 'notices',
149                'force_threshold' => '7.0',
150                // Only force-replace on older WP without Gutenberg: older Core
151                // versions ship notices without SnackbarNotices and InlineNotices
152                // component exports that @wordpress/boot depends on.
153            ),
154            'wp-private-apis' => array(
155                'path'                  => 'private-apis',
156                'force_threshold'       => '7.1',
157                'gutenberg_min_version' => self::GUTENBERG_PRIVATE_APIS_MIN_VERSION,
158                // WP 7.0 and older versions ship private-apis with an incomplete
159                // allowlist that rejects @wordpress/theme, @wordpress/route, and
160                // newer dashboard packages. Active Gutenberg is only a safe
161                // substitute once its private-apis allowlist includes those
162                // dashboard packages too.
163            ),
164            'wp-theme'        => array(
165                'path' => 'theme',
166            ),
167            'wp-views'        => array(
168                'path' => 'views',
169            ),
170        );
171
172        foreach ( $polyfills as $handle => $data ) {
173            if ( ! isset( self::$requested[ $handle ] ) ) {
174                continue;
175            }
176
177            $asset_file = $build_dir . '/scripts/' . $data['path'] . '/index.asset.php';
178
179            if ( ! file_exists( $asset_file ) ) {
180                continue;
181            }
182
183            $force_threshold = $data['force_threshold'] ?? null;
184            if ( null !== $force_threshold && version_compare( $wp_version_threshold, $force_threshold, '>' ) ) {
185                $force_threshold = $wp_version_threshold;
186            }
187
188            $force = null !== $force_threshold
189                && ! self::is_gutenberg_version_safe( $data['gutenberg_min_version'] ?? null, $gutenberg_version )
190                && version_compare( $GLOBALS['wp_version'] ?? '0', $force_threshold, '<' );
191
192            if ( ! $force && $scripts->query( $handle, 'registered' ) ) {
193                continue;
194            }
195
196            // Deregister first when forcing replacement of an existing registration.
197            if ( $force && $scripts->query( $handle, 'registered' ) ) {
198                $scripts->remove( $handle );
199            }
200
201            $asset = require $asset_file;
202
203            $scripts->add(
204                $handle,
205                plugins_url( 'build/scripts/' . $data['path'] . '/index.js', $base_file ),
206                $asset['dependencies'],
207                $asset['version']
208            );
209        }
210    }
211
212    /**
213     * Check whether the active Gutenberg plugin can satisfy a forced script.
214     *
215     * @param string|null $minimum_version   Minimum Gutenberg version required for the script, or null when any active Gutenberg is sufficient.
216     * @param string|null $gutenberg_version Active Gutenberg version, or null when Gutenberg is inactive.
217     * @return bool True when Gutenberg is active and new enough.
218     */
219    private static function is_gutenberg_version_safe( $minimum_version, $gutenberg_version ) {
220        if ( null === $gutenberg_version ) {
221            return false;
222        }
223
224        if ( null === $minimum_version ) {
225            return true;
226        }
227
228        return version_compare( $gutenberg_version, $minimum_version, '>=' );
229    }
230
231    /**
232     * Register polyfill script modules.
233     *
234     * Call to wp_register_script_module() silently ignores duplicate registrations (first wins),
235     * so no explicit is_registered check is needed.
236     *
237     * @param string $build_dir Absolute path to the build directory.
238     * @param string $base_file File path for plugins_url() computation.
239     */
240    private static function register_modules( $build_dir, $base_file ) {
241        if ( ! function_exists( 'wp_register_script_module' ) ) {
242            return;
243        }
244
245        $modules = array( 'boot', 'route', 'a11y' );
246
247        foreach ( $modules as $name ) {
248            $module_id = '@wordpress/' . $name;
249
250            if ( ! isset( self::$requested[ $module_id ] ) ) {
251                continue;
252            }
253
254            $asset_file = $build_dir . '/modules/' . $name . '/index.asset.php';
255
256            if ( ! file_exists( $asset_file ) ) {
257                continue;
258            }
259
260            $asset = require $asset_file;
261
262            wp_register_script_module(
263                $module_id,
264                plugins_url( 'build/modules/' . $name . '/index.js', $base_file ),
265                $asset['module_dependencies'] ?? array(),
266                $asset['version']
267            );
268        }
269    }
270}