Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.19% covered (success)
91.19%
145 / 159
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Enqueue_Dynamic_Script
91.19% covered (success)
91.19%
145 / 159
46.15% covered (danger)
46.15%
6 / 13
66.80
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_admin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_script
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 dequeue_script
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_statically_enqueued
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_ordered_scripts
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 output_inline_script
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_script_url
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
11.14
 inject_loader_scripts
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 build_script_data
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
10
 output_inline_scripts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_loading_orchestration_scripts
90.70% covered (success)
90.70%
39 / 43
0.00% covered (danger)
0.00%
0 / 1
15.18
1<?php
2/**
3 * WPCOM Enqueue Dynamic Script
4 *
5 * @see ./README.md
6 *
7 * @package automattic/jetpack-mu-wpcom
8 */
9
10/**
11 * Class WPCOM_Enqueue_Dynamic_Script
12 */
13class WPCOM_Enqueue_Dynamic_Script {
14    /**
15     * Enqueued scripts that are candidates for dynamic loading.
16     *
17     * @var string[]
18     */
19    private static $dynamic_scripts = array();
20
21    /**
22     * Whether the init method has been called.
23     *
24     * @var bool
25     */
26    private static $init_done = false;
27
28    /**
29     * Add the JS orchestration script to the footer.
30     */
31    public static function init() {
32        add_action( 'wp_footer', array( 'WPCOM_Enqueue_Dynamic_Script', 'inject_loader_scripts' ), 99999 );
33    }
34
35    /**
36     * Add the JS orchestration script to the footer for wp-admin pages.
37     */
38    public static function init_admin() {
39        add_action( 'admin_footer', array( 'WPCOM_Enqueue_Dynamic_Script', 'inject_loader_scripts' ), 99999 );
40    }
41
42    /**
43     * Enqueue a script for dynamic loading.
44     * Adds registered scripts to dynamic handler and inject JS orchestration script to the footer.
45     *
46     * @param string $handle The registered handle for the script.
47     */
48    public static function enqueue_script( $handle ) {
49        $wp_scripts = wp_scripts();
50
51        if ( ! self::$init_done ) {
52            self::$init_done = true;
53            if ( is_admin() ) {
54                self::init_admin();
55            } else {
56                self::init();
57            }
58        }
59
60        if ( empty( $wp_scripts->registered[ $handle ] ) ) {
61            wp_trigger_error( 'WPCOM_Enqueue_Dynamic_Script::enqueue_script', "unknown script '{$handle}'.", E_USER_WARNING );
62            return false;
63        }
64
65        self::$dynamic_scripts[] = $handle;
66    }
67
68    /**
69     * Dequeue a script that was previously enqueued for dynamic loading.
70     *
71     * @param string $handle The registered handle for the script.
72     */
73    public static function dequeue_script( $handle ) {
74        $index = array_search( $handle, self::$dynamic_scripts, true );
75
76        if ( false !== $index ) {
77            unset( self::$dynamic_scripts[ $index ] );
78
79            // Re-index the array
80            self::$dynamic_scripts = array_values( self::$dynamic_scripts );
81        }
82    }
83
84    /**
85     * Reset the state of the class and remove the JS control scripts.
86     */
87    public static function reset() {
88        self::$dynamic_scripts = array();
89        self::$init_done       = false;
90        remove_action( 'wp_footer', array( 'WPCOM_Enqueue_Dynamic_Script', 'inject_loader_scripts' ), 99999 );
91        remove_action( 'admin_footer', array( 'WPCOM_Enqueue_Dynamic_Script', 'inject_loader_scripts' ), 99999 );
92    }
93
94    /**
95     * Check if a script is already enqueued statically.
96     *
97     * @param string $handle The handle for the script.
98     */
99    public static function is_statically_enqueued( $handle ) {
100        $wp_scripts = wp_scripts();
101
102        return $wp_scripts->query( $handle, 'enqueued' ) ||
103            $wp_scripts->query( $handle, 'to_do' ) ||
104            $wp_scripts->query( $handle, 'done' );
105    }
106
107    /**
108     * Get a list of scripts ordered based on their dependencies.
109     *
110     * @param string[] $handles The registered handles for the scripts.
111     *
112     * @return array
113     */
114    public static function get_ordered_scripts( $handles ) {
115        $wp_scripts = wp_scripts();
116
117        $list = array();
118
119        // Handle a script and all its dependencies.
120        // This closure calls itself recursively.
121        $get_sub_deps = function ( $handle ) use ( &$list, &$wp_scripts, &$get_sub_deps ) {
122            $script = $wp_scripts->query( $handle, 'registered' );
123
124            if ( empty( $script ) ) {
125                wp_trigger_error( 'WPCOM_Enqueue_Dynamic_Script::get_ordered_scripts', "unknown script '{$handle}'.", E_USER_WARNING );
126                return;
127            }
128
129            if ( ! empty( $list[ $handle ] ) ) {
130                // This script is already added to the list; skip processing it again.
131                return;
132            }
133
134            if ( self::is_statically_enqueued( $handle ) ) {
135                // Top-level script that's already statically enqueued.
136                // Treat it as having no dependencies and move on.
137                $list[ $handle ] = array();
138                return;
139            }
140
141            $deps          = array();
142            $filtered_deps = array();
143
144            // Process script dependencies first.
145            if ( ! empty( $script->deps ) ) {
146                $deps = $script->deps;
147            }
148
149            foreach ( $deps as $dep ) {
150                // Skip dependencies that are already statically enqueued, and remove them from the
151                // dependency list for the script.
152                if ( ! self::is_statically_enqueued( $dep ) ) {
153                    $get_sub_deps( $dep );
154                    $filtered_deps[] = $dep;
155                }
156            }
157
158            // If by this point the script is still not in the list, add it.
159            if ( empty( $list[ $handle ] ) ) {
160                $list[ $handle ] = $filtered_deps;
161            }
162        };
163
164        // Handle all registered top-level scripts.
165        foreach ( $handles as $handle ) {
166            $get_sub_deps( $handle );
167        }
168
169        return $list;
170    }
171
172    /**
173     * Output the HTML for an inline script.
174     *
175     * @param string $parent The handle for the parent script.
176     * @param string $position The position for the inline script; 'before' or 'after' the parent.
177     * @param int    $index The (1-based) index for the script, within a given position.
178     * @param string $code The JS code to be placed inside the script tag.
179     */
180    public static function output_inline_script( $parent, $position, $index, $code ) {
181        $out = "\n<script type='disabled' id='wp-enqueue-dynamic-script:{$parent}:{$position}:{$index}'>\n$code\n</script>\n";
182        echo $out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
183    }
184
185    /**
186     * Get the URL for a script, including version arg and extra args.
187     *
188     * @param _WP_Dependency $script The script to get the URL for.
189     */
190    public static function get_script_url( $script ) {
191        $src    = $script->src;
192        $handle = $script->handle;
193
194        if ( empty( $src ) ) {
195            return '';
196        }
197
198        $wp_scripts = wp_scripts();
199
200        // Handle version URL argument.
201        $ver = $script->ver;
202        if ( null === $ver ) {
203            $ver = '';
204        } else {
205            $ver = $ver ? $ver : $wp_scripts->default_version;
206        }
207
208        // Handle top-level script that's statically enqueued.
209        // Return an empty src, so that it's treated as a dummy script, thus resolving immediately on
210        // the client.
211        if ( self::is_statically_enqueued( $handle ) ) {
212            return '';
213        }
214
215        // Handle extra URL arguments.
216        if ( isset( $wp_scripts->args[ $handle ] ) ) {
217            $ver = $ver ? $ver . '&' . $wp_scripts->args[ $handle ] : $wp_scripts->args[ $handle ];
218        }
219
220        // Replace relative URLs with absolute ones.
221        if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && 0 === strpos( $src, $wp_scripts->content_url ) ) ) {
222            $src = $wp_scripts->base_url . $src;
223        }
224
225        if ( ! empty( $ver ) ) {
226            $src = add_query_arg( 'ver', $ver, $src );
227        }
228
229        // Apply any existing filters to URL before returning.
230        $src = apply_filters( 'script_loader_src', $src, $handle );
231        return $src;
232    }
233
234    /**
235     * Generate and inject the loading orchestration JS into the HTML.
236     * The generated JS embeds all of the necessary information to load the registered scripts, their
237     * transitive dependencies, and extra inline scripts ('before' / 'after' scripts) at runtime.
238     */
239    public static function inject_loader_scripts() {
240        if ( empty( self::$dynamic_scripts ) ) {
241            return;
242        }
243
244        $script_data = self::build_script_data();
245        self::output_inline_scripts( $script_data );
246        $loading_code = self::get_loading_orchestration_scripts( $script_data );
247        echo "\n<script>\n$loading_code\n</script>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
248    }
249
250    /**
251     * Build the data structure that will be used by the loading orchestration script.
252     */
253    public static function build_script_data() {
254        if ( empty( self::$dynamic_scripts ) ) {
255            return;
256        }
257
258        $wp_scripts = wp_scripts();
259
260        $all_scripts = self::get_ordered_scripts( self::$dynamic_scripts );
261        $script_data = array(
262            'urls'   => array(),
263            'extras' => array(),
264            'loader' => array(),
265        );
266
267        // Start by determining the location of each script, and which of them include extra inline
268        // scripts ('before' / 'after' scripts).
269        foreach ( $all_scripts as $handle => $deps ) {
270            $script = $wp_scripts->registered[ $handle ];
271
272            $src                            = self::get_script_url( $script );
273            $script_data['urls'][ $handle ] = $src;
274
275            $extras = array(
276                'translations' => array(),
277                'before'       => array(),
278                'after'        => array(),
279            );
280
281            // Is this a statically-enqueued top level script?
282            // If so, it shouldn't have any extras, because they've already been handled statically.
283            if ( in_array( $handle, self::$dynamic_scripts, true ) && self::is_statically_enqueued( $handle ) ) {
284                $script_data['extras'][ $handle ] = $extras;
285                continue;
286            }
287
288            // Aux function to be used as a filter for empty items.
289            $filter_empty = function ( $x ) {
290                return ! empty( $x );
291            };
292
293            // Handle 'before' scripts.
294            if ( ! empty( $script->extra['before'] ) ) {
295                $extras['before'] = array_values( array_filter( $script->extra['before'], $filter_empty ) );
296            }
297
298            // Handle 'after' scripts.
299            if ( ! empty( $script->extra['after'] ) ) {
300                $extras['after'] = array_values( array_filter( $script->extra['after'], $filter_empty ) );
301            }
302
303            // Handle 'translations' scripts.
304            $translations = $wp_scripts->print_translations( $handle, false );
305            if ( isset( $script->textdomain ) && $translations ) {
306                $extras['translations'] = array( $translations );
307            }
308
309            $script_data['extras'][ $handle ] = $extras;
310        }
311
312        // Determine the loading sequence for each top-level (enqueued) script.
313        foreach ( self::$dynamic_scripts as $top_script ) {
314            $script_data['loader'][ $top_script ] = self::get_ordered_scripts( array( $top_script ) );
315        }
316
317        return $script_data;
318    }
319
320    /**
321     * Output all 'translations', 'before' and 'after' inline scripts as disabled <script> tags.
322     *
323     * The browser won't run these directly; instead, the loading script will look for them and
324     * treat them as templates, copying their contents into newly-instanced script tags at the right
325     * moment. This ensures that they don't execute too early, nor too late.
326     *
327     * @param array $script_data The data structure with the script information.
328     */
329    public static function output_inline_scripts( $script_data ) {
330        foreach ( $script_data['extras'] as $handle => $positions ) {
331            foreach ( $positions as $position => $scripts ) {
332                foreach ( $scripts as $index => $script ) {
333                    self::output_inline_script( $handle, $position, $index + 1, $script );
334                }
335            }
336        }
337    }
338
339    /**
340     * Generate the loading orchestration script.
341     *
342     * @param array $script_data The data structure with the script information.
343     */
344    public static function get_loading_orchestration_scripts( $script_data ) {
345        // Urls.
346        $script_url_list = implode(
347            ",\n\t\t",
348            array_map(
349                function ( $handle, $url ) {
350                    return "'{$handle}': '{$url}'";
351                },
352                array_keys( $script_data['urls'] ),
353                $script_data['urls']
354            )
355        );
356
357        // Extras.
358        $extras_meta = '';
359        foreach ( $script_data['extras'] as $handle => $positions ) {
360            $translations_count = is_countable( $positions['translations'] ) ? count( $positions['translations'] ) : 0;
361            $before_count       = is_countable( $positions['before'] ) ? count( $positions['before'] ) : 0;
362            $after_count        = is_countable( $positions['after'] ) ? count( $positions['after'] ) : 0;
363            if ( $before_count > 0 || $after_count > 0 || $translations_count > 0 ) {
364                $extras_meta .= "'{$handle}': { translations: {$translations_count}, before: {$before_count}, after: {$after_count} },\n\t\t";
365            }
366        }
367
368        // Loaders.
369        $loaders = '';
370        foreach ( $script_data['loader'] as $handle => $deps_and_top_script ) {
371            $loading_code = '';
372
373            /**
374             * First, start a fetch for each script (the top-level script and all of its transitive deps).
375             * The goal here is to place all of the scripts in the cache, so that once we add the <script>
376             * tag they get pulled from cache, rather than rely on the tag itself to download the script.
377             * This helps optimise bandwidth usage and avoid wide waterfalls.
378             *
379             * Note that this means that if the cache is disabled (e.g. when disabling cache in DevTools),
380             * scripts will be fetched twice. Hopefully this is rare in the real world.
381             */
382
383            foreach ( $deps_and_top_script as $script => $deps ) {
384                $loading_code .= "fetchExternalScript('{$script}');\n\t\t\t";
385            }
386
387            // Next, output the promise chain for each script.
388            foreach ( $deps_and_top_script as $script => $deps ) {
389                $loading_code .= "promises['{$script}'] = promises['{$script}'] || ";
390
391                if ( empty( $deps ) ) {
392                    // No dependencies; load directly.
393                    $loading_code .= "loadWPScript('{$script}');";
394                } elseif ( is_countable( $deps ) && 1 === count( $deps ) ) {
395                    // One dependency; wait for it to load before loading script.
396                    $dep           = $deps[0];
397                    $loading_code .= "promises['{$dep}'].then( () => loadWPScript('{$script}') );";
398                } else {
399                    // Multiple dependencies; wait for all of them to load before loading script.
400                    $dep_list = '';
401                    foreach ( $deps as $dep ) {
402                        $dep_list .= "promises['{$dep}'], ";
403                    }
404                    $loading_code .= "Promise.all( [ {$dep_list} ] ).then( () => loadWPScript('{$script}') );";
405                }
406
407                $loading_code .= "\n\t\t\t";
408            }
409
410            // The final step is to return the promise for the top-level script, which will only resolve
411            // after everything else has.
412            $loading_code .= "return promises['{$handle}'];";
413            $loaders      .= "'{$handle}': () => {\n\t\t\t{$loading_code}\n\t\t},\n\t\t";
414        }
415
416        /**
417         * Finally, generate the full loading orchestration script.
418         * Here we piece together the various bits we've already generated together with the generic JS
419         * functions that handle the rest.
420         *
421         * Note: we use string concatenation instead of JS templated strings in the below JS, since PHP
422         * gets them confused with its own placeholders (which we do use).
423         */
424
425        $loading_script = <<<JAVASCRIPT
426        (function() {
427            'use strict';
428
429            const fetches = {};
430            const promises = {};
431            const urls = {
432                $script_url_list
433            };
434            const loaders = {
435                $loaders
436            };
437            const scriptExtras = {
438                $extras_meta
439            };
440
441            window.WPCOM_Enqueue_Dynamic_Script = {
442                loadScript: (handle) => {
443                    if (!loaders[handle]) {
444                        console.error('WPCOM_Enqueue_Dynamic_Script: unregistered script `' + handle + '`.');
445                    }
446                    return loaders[handle]();
447                }
448            };
449
450            function fetchExternalScript(handle) {
451                if (!urls[handle]) {
452                    return Promise.resolve();
453                }
454
455                fetches[handle] = fetches[handle] || fetch(urls[handle], { mode: 'no-cors' });
456                return fetches[handle];
457            }
458
459            function runExtraScript(handle, type, index) {
460                const id = 'wp-enqueue-dynamic-script:' + handle + ':' + type + ':' + (index + 1);
461                const template = document.getElementById(id);
462                if (!template) {
463                    return Promise.reject();
464                }
465
466                const script = document.createElement( 'script' );
467                script.innerHTML = template.innerHTML;
468                document.body.appendChild( script );
469                return Promise.resolve();
470            }
471
472            function loadExternalScript(handle) {
473                if (!urls[handle]) {
474                    return Promise.resolve();
475                }
476
477                return fetches[handle].then(() => {
478                    return new Promise((resolve, reject) => {
479                        const script = document.createElement('script');
480                        script.onload = () => resolve();
481                        script.onerror = (e) => reject(e);
482                        script.src = urls[handle];
483                        document.body.appendChild(script);
484                    });
485                });
486            }
487
488            function loadExtra(handle, pos) {
489                const count = (scriptExtras[handle] && scriptExtras[handle][pos]) || 0;
490                let promise = Promise.resolve();
491
492                for (let i = 0; i < count; i++) {
493                    promise = promise.then(() => runExtraScript(handle, pos, i));
494                }
495
496                return promise;
497            }
498
499            function loadWPScript(handle) {
500                // Core loads scripts in this order. See: https://github.com/WordPress/WordPress/blob/a59eb9d39c4fcba834b70c9e8dfd64feeec10ba6/wp-includes/class-wp-scripts.php#L428.
501                return loadExtra(handle, 'translations')
502                    .then(() => loadExtra(handle, 'before'))
503                    .then(() => loadExternalScript(handle))
504                    .then(() => loadExtra(handle, 'after'));
505            }
506        } )();
507JAVASCRIPT;
508
509        return $loading_script;
510    }
511}