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