Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
170 / 172
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WP_Build_Polyfills
98.84% covered (success)
98.84%
170 / 172
71.43% covered (warning)
71.43%
5 / 7
65
0.00% covered (danger)
0.00%
0 / 1
 register
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
18
 page_filter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 add_site_data_preload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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%
69 / 69
100.00% covered (success)
100.00%
1 / 1
19
 is_gutenberg_version_safe
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_modules
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Polyfill registration for Core packages not available or incomplete in older WordPress versions.
4 *
5 * On WordPress 7.0 at the default threshold, only wp-private-apis and wp-rich-text replace Core's
6 * copies; every other polyfill fills in what Core lacks, or replaces a Gutenberg copy too old to use.
7 *
8 * @package automattic/jetpack-wp-build-polyfills
9 */
10
11namespace Automattic\Jetpack\WP_Build_Polyfills;
12
13/**
14 * Registers polyfill scripts and modules for WordPress Core packages.
15 */
16class WP_Build_Polyfills {
17
18    /**
19     * Available polyfill handles for classic scripts.
20     */
21    const SCRIPT_HANDLES = array( 'wp-notices', 'wp-private-apis', 'wp-rich-text', 'wp-theme', 'wp-views' );
22
23    /**
24     * Available polyfill module IDs.
25     */
26    const MODULE_IDS = array( '@wordpress/boot', '@wordpress/route', '@wordpress/a11y', '@wordpress/widget-primitives' );
27
28    /**
29     * Polyfills that only work when another polyfill is registered alongside them.
30     *
31     * These bundles call `__dangerousOptInToUnstableAPIsOnlyForCoreModules()` at
32     * module scope, which throws unless the `wp-private-apis` implementation that
33     * actually loads allowlists their package name. WP 7.0's allowlist omits
34     * `@wordpress/views` and `@wordpress/compose` (bundled into the rich-text
35     * polyfill), so requesting either polyfill without `wp-private-apis` blanks
36     * the page. It does allow `@wordpress/theme`, and WP 7.0 registers `wp-theme`
37     * itself, so the theme polyfill never loads there.
38     *
39     * The `wp-private-apis` script dependency in each `.asset.php` is not enough
40     * on its own — it makes WordPress enqueue the *handle*, which resolves to
41     * Core's incomplete implementation unless the polyfill was requested too.
42     *
43     * @var array<string, string[]>
44     */
45    const SCRIPT_DEPENDENCIES = array(
46        'wp-rich-text' => array( 'wp-private-apis' ),
47        'wp-theme'     => array( 'wp-private-apis' ),
48        'wp-views'     => array( 'wp-private-apis' ),
49    );
50
51    /**
52     * Minimum Gutenberg plugin version known to ship a private-apis allowlist
53     * that includes the dashboard packages used by this package's current build.
54     */
55    const GUTENBERG_PRIVATE_APIS_MIN_VERSION = '23.5.0';
56
57    /**
58     * Minimum Gutenberg plugin version whose rich-text ships all the privateApis
59     * keys dashboard packages unlock (useRichText, KeyboardShortcutContext,
60     * InputEventContext, shortcutsListener, inputEventsListener). They were
61     * completed by Gutenberg PR #78471, first released in 23.6.0 — verified
62     * against the released builds: 23.5.0 lacks three of the five keys.
63     */
64    const GUTENBERG_RICH_TEXT_MIN_VERSION = '23.6.0';
65
66    /**
67     * Minimum Gutenberg plugin version whose widget-primitives script module ships
68     * `HostLink`, which widget-dashboard >= 0.8.0 imports at module scope (Gutenberg
69     * PR #82952, first released in 24.1.0).
70     */
71    const GUTENBERG_WIDGET_PRIMITIVES_MIN_VERSION = '24.1.0';
72
73    /**
74     * Global holding the bundled package version each polyfilled module was registered from.
75     *
76     * Shared across the package copies the active plugins bundle, so a newer copy can replace
77     * an older registration and an older copy leaves a newer one alone.
78     */
79    const MODULE_VERSIONS_GLOBAL = 'jetpack_wp_build_polyfills_module_versions';
80
81    /**
82     * Tracks which polyfills have been requested and by which consumers.
83     *
84     * Keys are polyfill handles/module IDs, values are arrays of consumer names.
85     *
86     * @var array<string, string[]>
87     */
88    private static $requested = array();
89
90    /**
91     * Whether registration has already run, or been hooked to wp_default_scripts.
92     *
93     * @var bool
94     */
95    private static $hooked = false;
96
97    /**
98     * The WordPress version below which force-replacements are applied.
99     * When multiple consumers call register() with different thresholds,
100     * the highest threshold wins (most conservative approach).
101     *
102     * @var string
103     */
104    private static $wp_version_threshold = '7.0';
105
106    /**
107     * Overrides the build directory. Test seam, null in production.
108     *
109     * @var string|null
110     */
111    private static $build_dir_override = null;
112
113    /**
114     * Register polyfill scripts and modules.
115     *
116     * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts
117     * at priority 20 so Core (default) and Gutenberg (priority 10) register first.
118     *
119     * When multiple consumers call this method with different thresholds, the
120     * highest threshold wins (most conservative — polyfills active on more versions).
121     *
122     * Polyfills listed in SCRIPT_DEPENDENCIES pull in their companion polyfill
123     * automatically, so consumers cannot request a combination that throws at
124     * load time. Those companions show up in get_consumers() under the
125     * requesting consumer's name.
126     *
127     * Every call also arms WP_Build_Admin_Frame for the request, which keeps the boot
128     * single-page layout in step with the wp-admin frame on every wp-build page.
129     * Each call adds the site-data preload filter for its supplied wp-build pages.
130     *
131     * @param string   $consumer             A unique identifier for the consumer (e.g. plugin slug).
132     * @param string[] $polyfills             List of polyfill handles/module IDs to register.
133     *                                        Use class constants SCRIPT_HANDLES and MODULE_IDS for reference.
134     * @param string   $wp_version_threshold  The WordPress version below which force-replacements
135     *                                        are applied. Defaults to '7.0'.
136     * @param array[]  $pages                 wp-build pages, each with a 'slug' and an optional 'prefix'
137     *                                        matching wpPlugin.name (defaults to 'gutenberg').
138     */
139    public static function register( $consumer, $polyfills, $wp_version_threshold = '7.0', $pages = array() ) {
140        WP_Build_Admin_Frame::register();
141
142        foreach ( $pages as $page ) {
143            if ( ! is_array( $page ) || ! isset( $page['slug'] ) || ! is_string( $page['slug'] ) || '' === $page['slug'] ) {
144                continue;
145            }
146            add_filter( self::page_filter( $page, 'wp_admin_preload_paths' ), array( __CLASS__, 'add_site_data_preload' ) );
147        }
148
149        $added = array();
150        foreach ( $polyfills as $handle ) {
151            if ( ! in_array( $handle, self::SCRIPT_HANDLES, true ) && ! in_array( $handle, self::MODULE_IDS, true ) ) {
152                continue;
153            }
154
155            $required = array_merge( array( $handle ), self::SCRIPT_DEPENDENCIES[ $handle ] ?? array() );
156
157            foreach ( $required as $required_handle ) {
158                if ( ! isset( self::$requested[ $required_handle ] ) ) {
159                    self::$requested[ $required_handle ] = array();
160                    $added[]                             = $required_handle;
161                }
162                if ( ! in_array( $consumer, self::$requested[ $required_handle ], true ) ) {
163                    self::$requested[ $required_handle ][] = $consumer;
164                }
165            }
166        }
167
168        $raised = version_compare( $wp_version_threshold, self::$wp_version_threshold, '>' );
169        if ( $raised ) {
170            self::$wp_version_threshold = $wp_version_threshold;
171        }
172
173        $package_root = dirname( __DIR__ );
174        $build_dir    = self::$build_dir_override ?? $package_root . '/build';
175        $base_file    = $package_root . '/composer.json';
176
177        if ( self::$hooked ) {
178            // Registration already ran, so apply this call's new polyfills and raised threshold to it.
179            if ( ( $raised || $added ) && did_action( 'wp_default_scripts' ) ) {
180                self::register_scripts( wp_scripts(), $build_dir, $base_file, self::$wp_version_threshold );
181                self::register_modules( $build_dir, $base_file, $added );
182            }
183            return;
184        }
185        self::$hooked = true;
186
187        // `wp_default_scripts` fires when `wp_scripts()` creates the WP_Scripts singleton, and again on
188        // `init` if it was created before then. Once it has fired (common on admin requests by
189        // `admin_menu`), a hook added now may never run, so register synchronously instead.
190        if ( did_action( 'wp_default_scripts' ) ) {
191            self::register_scripts( wp_scripts(), $build_dir, $base_file, self::$wp_version_threshold );
192            self::register_modules( $build_dir, $base_file );
193            return;
194        }
195
196        add_action(
197            'wp_default_scripts',
198            function ( $scripts ) use ( $build_dir, $base_file ) {
199                self::register_scripts( $scripts, $build_dir, $base_file, self::$wp_version_threshold );
200                self::register_modules( $build_dir, $base_file );
201            },
202            20
203        );
204    }
205
206    /**
207     * Construct a wp-build page's filter name.
208     *
209     * @param array  $page   Page slug and optional plugin prefix.
210     * @param string $suffix Filter suffix.
211     * @return string Filter name.
212     */
213    private static function page_filter( $page, $suffix ) {
214        $prefix = $page['prefix'] ?? 'gutenberg';
215        if ( '' === $prefix ) {
216            $prefix = 'gutenberg';
217        }
218        return str_replace( '-', '_', $prefix . '_' . $page['slug'] ) . '_' . $suffix;
219    }
220
221    /**
222     * Add the site-data request used by WordPress 7.1.
223     *
224     * @since $$next-version$$
225     * @param array $paths REST API paths to preload.
226     * @return array REST API paths to preload.
227     */
228    public static function add_site_data_preload( $paths ) {
229        // Keep site data preloaded until no supported WordPress uses the 7.1 field list.
230        // https://github.com/WordPress/gutenberg/pull/86185 keeps routes available after failure but does not change the list.
231        $path = '/?_fields=description,gmt_offset,home,image_max_bit_depth,image_sizes,image_size_threshold,image_strip_meta,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front';
232        if ( ! in_array( $path, $paths, true ) ) {
233            $paths[] = $path;
234        }
235        return $paths;
236    }
237
238    /**
239     * Get the map of requested polyfills and their consumers.
240     *
241     * @return array<string, string[]> Keys are polyfill handles/module IDs, values are consumer names.
242     */
243    public static function get_consumers() {
244        return self::$requested;
245    }
246
247    /**
248     * Register polyfill classic scripts.
249     *
250     * @param \WP_Scripts $scripts               The WP_Scripts instance.
251     * @param string      $build_dir             Absolute path to the build directory.
252     * @param string      $base_file             File path for plugins_url() computation.
253     * @param string      $wp_version_threshold  WP version below which force-replacements apply.
254     */
255    private static function register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ) {
256        // Force-replace only when Core's bundled scripts are incomplete and
257        // Gutenberg cannot be trusted to provide a compatible implementation.
258        $gutenberg_version = defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null;
259
260        $polyfills = array(
261            'wp-notices'      => array(
262                'path'            => 'notices',
263                'force_threshold' => '7.0',
264                // WP 7.0 ships the SnackbarNotices and InlineNotices exports
265                // @wordpress/boot depends on, so on a supported site this forces only
266                // when a consumer raises the threshold, and never with Gutenberg active.
267            ),
268            'wp-private-apis' => array(
269                'path'                  => 'private-apis',
270                'force_threshold'       => '7.1',
271                'gutenberg_min_version' => self::GUTENBERG_PRIVATE_APIS_MIN_VERSION,
272                // WP 7.0's private-apis allowlist rejects newer dashboard packages
273                // such as @wordpress/views and @wordpress/widget-dashboard. Active
274                // Gutenberg is only a safe substitute once its private-apis
275                // allowlist includes those dashboard packages too.
276            ),
277            'wp-rich-text'    => array(
278                'path'                  => 'rich-text',
279                'force_threshold'       => '7.1',
280                'gutenberg_min_version' => self::GUTENBERG_RICH_TEXT_MIN_VERSION,
281                // WP 7.0 ships a rich-text that locks only `useRichText` into
282                // `privateApis`, so current dashboard dependencies that unlock more
283                // keys at module scope (e.g. @wordpress/dataviews >= 17.2 dataform
284                // controls) get undefined and the page blanks. Older Gutenberg is
285                // not a safe substitute either — see the constant's doc.
286            ),
287            'wp-theme'        => array(
288                'path' => 'theme',
289            ),
290            'wp-views'        => array(
291                'path' => 'views',
292            ),
293        );
294
295        foreach ( $polyfills as $handle => $data ) {
296            if ( ! isset( self::$requested[ $handle ] ) ) {
297                continue;
298            }
299
300            $asset_file = $build_dir . '/scripts/' . $data['path'] . '/index.asset.php';
301
302            if ( ! file_exists( $asset_file ) ) {
303                continue;
304            }
305
306            $src = plugins_url( 'build/scripts/' . $data['path'] . '/index.js', $base_file );
307
308            // Already ours from an earlier register() call; replacing it would drop anything attached since.
309            $registered = $scripts->query( $handle, 'registered' );
310            if ( $registered && $src === $registered->src ) {
311                continue;
312            }
313
314            $force_threshold = $data['force_threshold'] ?? null;
315            if ( null !== $force_threshold && version_compare( $wp_version_threshold, $force_threshold, '>' ) ) {
316                $force_threshold = $wp_version_threshold;
317            }
318
319            $force = null !== $force_threshold
320                && ! self::is_gutenberg_version_safe( $data['gutenberg_min_version'] ?? null, $gutenberg_version )
321                && version_compare( $GLOBALS['wp_version'] ?? '0', $force_threshold, '<' );
322
323            if ( ! $force && $scripts->query( $handle, 'registered' ) ) {
324                continue;
325            }
326
327            // Deregister first when forcing replacement of an existing registration.
328            // `remove()` drops everything Core set up alongside the src — notably
329            // `$args` (Core registers package scripts with `1`, i.e. in the footer)
330            // and the registered translations. Both are restored after `add()` so
331            // the replacement is a drop-in for the registration it displaces.
332            $replaced = null;
333            if ( $force && $scripts->query( $handle, 'registered' ) ) {
334                $replaced = $scripts->registered[ $handle ];
335                $scripts->remove( $handle );
336            }
337
338            $asset = require $asset_file;
339
340            $scripts->add(
341                $handle,
342                $src,
343                $asset['dependencies'],
344                $asset['version'],
345                // Match Core's `wp_default_packages_scripts()`, which registers every
346                // `wp-*` package script in the footer.
347                null !== $replaced ? $replaced->args : 1
348            );
349
350            if ( null !== $replaced && null !== $replaced->textdomain ) {
351                $scripts->set_translations( $handle, $replaced->textdomain, $replaced->translations_path );
352            } elseif ( in_array( 'wp-i18n', $asset['dependencies'], true ) ) {
353                // Same rule Core applies when registering its own package scripts.
354                // Translations resolve via `{locale}-{handle}.json`, which is keyed
355                // by handle, so the polyfill's own src path does not break the lookup.
356                $scripts->set_translations( $handle );
357            }
358        }
359    }
360
361    /**
362     * Check whether the active Gutenberg plugin can satisfy a forced script.
363     *
364     * @param string|null $minimum_version   Minimum Gutenberg version required for the script, or null when any active Gutenberg is sufficient.
365     * @param string|null $gutenberg_version Active Gutenberg version, or null when Gutenberg is inactive.
366     * @return bool True when Gutenberg is active and new enough.
367     */
368    private static function is_gutenberg_version_safe( $minimum_version, $gutenberg_version ) {
369        if ( null === $gutenberg_version ) {
370            return false;
371        }
372
373        if ( null === $minimum_version ) {
374            return true;
375        }
376
377        return version_compare( $gutenberg_version, $minimum_version, '>=' );
378    }
379
380    /**
381     * Register polyfill script modules.
382     *
383     * Calls to wp_register_script_module() silently ignore duplicate registrations (first wins), so an
384     * already registered module is left alone unless it is known to be too old for this package's
385     * current build: Gutenberg's copy below the minimum version, or another plugin's copy of this
386     * package bundling an older package version. Those are replaced.
387     *
388     * @param string        $build_dir  Absolute path to the build directory.
389     * @param string        $base_file  File path for plugins_url() computation.
390     * @param string[]|null $module_ids Only these requested module IDs, or null for all of them.
391     */
392    private static function register_modules( $build_dir, $base_file, $module_ids = null ) {
393        if ( ! function_exists( 'wp_register_script_module' ) ) {
394            return;
395        }
396
397        $gutenberg_version = defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null;
398
399        if ( ! isset( $GLOBALS[ self::MODULE_VERSIONS_GLOBAL ] ) || ! is_array( $GLOBALS[ self::MODULE_VERSIONS_GLOBAL ] ) ) {
400            $GLOBALS[ self::MODULE_VERSIONS_GLOBAL ] = array();
401        }
402        $registered_versions = &$GLOBALS[ self::MODULE_VERSIONS_GLOBAL ];
403
404        $modules = array(
405            'boot'              => array(),
406            'route'             => array(),
407            'a11y'              => array(),
408            'widget-primitives' => array(
409                // Gutenberg re-registers Core's script modules with its own copies, and
410                // older ones lack exports widget-dashboard imports at module scope. The
411                // replacement only adds exports, so Gutenberg's own consumers keep working.
412                'gutenberg_min_version' => self::GUTENBERG_WIDGET_PRIMITIVES_MIN_VERSION,
413                // Core never ships it, so outside Gutenberg the newest bundled copy wins.
414                'newest_copy_wins'      => true,
415            ),
416        );
417
418        foreach ( $modules as $name => $data ) {
419            $module_id = '@wordpress/' . $name;
420
421            if ( ! isset( self::$requested[ $module_id ] ) ) {
422                continue;
423            }
424
425            if ( null !== $module_ids && ! in_array( $module_id, $module_ids, true ) ) {
426                continue;
427            }
428
429            $asset_file = $build_dir . '/modules/' . $name . '/index.asset.php';
430
431            if ( ! file_exists( $asset_file ) ) {
432                continue;
433            }
434
435            $asset = require $asset_file;
436
437            $gutenberg_too_old = isset( $data['gutenberg_min_version'] )
438                && null !== $gutenberg_version
439                && ! self::is_gutenberg_version_safe( $data['gutenberg_min_version'], $gutenberg_version );
440
441            // Builds without `package_version` predate it and count as older than any that does.
442            $version = $asset['package_version'] ?? null;
443            $replace = $gutenberg_too_old;
444            $record  = false;
445
446            if ( ! empty( $data['newest_copy_wins'] ) ) {
447                if ( array_key_exists( $module_id, $registered_versions ) ) {
448                    $current = $registered_versions[ $module_id ];
449                    if ( null === $version || ( null !== $current && version_compare( $version, $current, '<=' ) ) ) {
450                        continue;
451                    }
452                    $replace = true;
453                    $record  = true;
454                } else {
455                    // With a supported Gutenberg its copy stays; otherwise nobody else ships it.
456                    $record = null === $gutenberg_version || $gutenberg_too_old;
457                }
458            }
459
460            if ( $replace ) {
461                wp_deregister_script_module( $module_id );
462            }
463
464            wp_register_script_module(
465                $module_id,
466                plugins_url( 'build/modules/' . $name . '/index.js', $base_file ),
467                $asset['module_dependencies'] ?? array(),
468                $asset['version']
469            );
470
471            if ( $record ) {
472                $registered_versions[ $module_id ] = $version;
473            }
474        }
475    }
476}