Make WordPress Core

Changeset 60931


Ignore:
Timestamp:
10/14/2025 05:45:17 AM (8 weeks ago)
Author:
westonruter
Message:

Script Loader: Propagate fetchpriority from dependents to dependencies.

This introduces a "fetchpriority bumping" mechanism for both classic scripts (WP_Scripts) and script modules (WP_Script_Modules). When a script with a higher fetchpriority is enqueued, any of its dependencies will have their fetchpriority elevated to match that of the highest-priority dependent. This ensures that all assets in a critical dependency chain are loaded with the appropriate priority, preventing a high-priority script from being blocked by a low-priority dependency. This is similar to logic used in script loading strategies to ensure that a blocking dependent causes delayed (async/defer) dependencies to also become blocking. See #12009.

When a script's fetchpriority is escalated, its original, registered priority is added to the tag via a data-wp-fetchpriority attribute. This matches the addition of the data-wp-strategy parameter added when the resulting loading strategy does not match the original.

Developed in https://github.com/WordPress/wordpress-develop/pull/9770.

Follow-up to [60704].

Props westonruter, jonsurrell.
Fixes #61734.

Location:
trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/class-wp-dependency.php

    r60656 r60931  
    5151     *
    5252     * @since 2.6.0
    53      * @var bool|string
     53     * @var string|false|null
    5454     */
    5555    public $ver = false;
  • trunk/src/wp-includes/class-wp-script-modules.php

    r60930 r60931  
    4141     */
    4242    private $a11y_available = false;
     43
     44    /**
     45     * Holds a mapping of dependents (as IDs) for a given script ID.
     46     * Used to optimize recursive dependency tree checks.
     47     *
     48     * @since 6.9.0
     49     * @var array<string, string[]>
     50     */
     51    private $dependents_map = array();
    4352
    4453    /**
     
    271280
    272281    /**
     282     * Gets the highest fetch priority for the provided script IDs.
     283     *
     284     * @since 6.9.0
     285     *
     286     * @param string[] $ids Script module IDs.
     287     * @return string Highest fetch priority for the provided script module IDs.
     288     */
     289    private function get_highest_fetchpriority( array $ids ): string {
     290        static $priorities   = array(
     291            'low',
     292            'auto',
     293            'high',
     294        );
     295        $high_priority_index = count( $priorities ) - 1;
     296
     297        $highest_priority_index = 0;
     298        foreach ( $ids as $id ) {
     299            if ( isset( $this->registered[ $id ] ) ) {
     300                $highest_priority_index = max(
     301                    $highest_priority_index,
     302                    array_search( $this->registered[ $id ]['fetchpriority'], $priorities, true )
     303                );
     304                if ( $high_priority_index === $highest_priority_index ) {
     305                    break;
     306                }
     307            }
     308        }
     309
     310        return $priorities[ $highest_priority_index ];
     311    }
     312
     313    /**
    273314     * Prints the enqueued script modules using script tags with type="module"
    274315     * attributes.
     
    283324                'id'   => $id . '-js-module',
    284325            );
    285             if ( 'auto' !== $script_module['fetchpriority'] ) {
    286                 $args['fetchpriority'] = $script_module['fetchpriority'];
     326
     327            $dependents    = $this->get_recursive_dependents( $id );
     328            $fetchpriority = $this->get_highest_fetchpriority( array_merge( array( $id ), $dependents ) );
     329            if ( 'auto' !== $fetchpriority ) {
     330                $args['fetchpriority'] = $fetchpriority;
     331            }
     332            if ( $fetchpriority !== $script_module['fetchpriority'] ) {
     333                $args['data-wp-fetchpriority'] = $script_module['fetchpriority'];
    287334            }
    288335            wp_print_script_tag( $args );
     
    291338
    292339    /**
    293      * Prints the the static dependencies of the enqueued script modules using
     340     * Prints the static dependencies of the enqueued script modules using
    294341     * link tags with rel="modulepreload" attributes.
    295342     *
     
    302349            // Don't preload if it's marked for enqueue.
    303350            if ( ! in_array( $id, $this->queue, true ) ) {
    304                 echo sprintf(
    305                     '<link rel="modulepreload" href="%s" id="%s"%s>',
     351                $enqueued_dependents   = array_intersect( $this->get_recursive_dependents( $id ), $this->queue );
     352                $highest_fetchpriority = $this->get_highest_fetchpriority( $enqueued_dependents );
     353                printf(
     354                    '<link rel="modulepreload" href="%s" id="%s"',
    306355                    esc_url( $this->get_src( $id ) ),
    307                     esc_attr( $id . '-js-modulepreload' ),
    308                     'auto' !== $script_module['fetchpriority'] ? sprintf( ' fetchpriority="%s"', esc_attr( $script_module['fetchpriority'] ) ) : ''
     356                    esc_attr( $id . '-js-modulepreload' )
    309357                );
     358                if ( 'auto' !== $highest_fetchpriority ) {
     359                    printf( ' fetchpriority="%s"', esc_attr( $highest_fetchpriority ) );
     360                }
     361                if ( $highest_fetchpriority !== $script_module['fetchpriority'] && 'auto' !== $script_module['fetchpriority'] ) {
     362                    printf( ' data-wp-fetchpriority="%s"', esc_attr( $script_module['fetchpriority'] ) );
     363                }
     364                echo ">\n";
    310365            }
    311366        }
     
    375430     * @return array[] List of dependencies, keyed by script module identifier.
    376431     */
    377     private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ) {
     432    private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ): array {
    378433        return array_reduce(
    379434            $ids,
    380435            function ( $dependency_script_modules, $id ) use ( $import_types ) {
    381436                $dependencies = array();
    382                 foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) {
    383                     if (
    384                     in_array( $dependency['import'], $import_types, true ) &&
    385                     isset( $this->registered[ $dependency['id'] ] ) &&
    386                     ! isset( $dependency_script_modules[ $dependency['id'] ] )
    387                     ) {
    388                         $dependencies[ $dependency['id'] ] = $this->registered[ $dependency['id'] ];
     437                if ( isset( $this->registered[ $id ] ) ) {
     438                    foreach ( $this->registered[ $id ]['dependencies'] as $dependency ) {
     439                        if (
     440                            in_array( $dependency['import'], $import_types, true ) &&
     441                            isset( $this->registered[ $dependency['id'] ] ) &&
     442                            ! isset( $dependency_script_modules[ $dependency['id'] ] )
     443                        ) {
     444                            $dependencies[ $dependency['id'] ] = $this->registered[ $dependency['id'] ];
     445                        }
    389446                    }
    390447                }
     
    393450            array()
    394451        );
     452    }
     453
     454    /**
     455     * Gets all dependents of a script module.
     456     *
     457     * This is not recursive.
     458     *
     459     * @since 6.9.0
     460     *
     461     * @see WP_Scripts::get_dependents()
     462     *
     463     * @param string $id The script ID.
     464     * @return string[] Script module IDs.
     465     */
     466    private function get_dependents( string $id ): array {
     467        // Check if dependents map for the handle in question is present. If so, use it.
     468        if ( isset( $this->dependents_map[ $id ] ) ) {
     469            return $this->dependents_map[ $id ];
     470        }
     471
     472        $dependents = array();
     473
     474        // Iterate over all registered scripts, finding dependents of the script passed to this method.
     475        foreach ( $this->registered as $registered_id => $args ) {
     476            if ( in_array( $id, wp_list_pluck( $args['dependencies'], 'id' ), true ) ) {
     477                $dependents[] = $registered_id;
     478            }
     479        }
     480
     481        // Add the module's dependents to the map to ease future lookups.
     482        $this->dependents_map[ $id ] = $dependents;
     483
     484        return $dependents;
     485    }
     486
     487    /**
     488     * Gets all recursive dependents of a script module.
     489     *
     490     * @since 6.9.0
     491     *
     492     * @see WP_Scripts::get_dependents()
     493     *
     494     * @param string $id The script ID.
     495     * @return string[] Script module IDs.
     496     */
     497    private function get_recursive_dependents( string $id ): array {
     498        $get = function ( string $id, array $checked = array() ) use ( &$get ): array {
     499
     500            // If by chance an unregistered script module is checked or there is a recursive dependency, return early.
     501            if ( ! isset( $this->registered[ $id ] ) || isset( $checked[ $id ] ) ) {
     502                return array();
     503            }
     504
     505            // Mark this script module as checked to guard against infinite recursion.
     506            $checked[ $id ] = true;
     507
     508            $dependents = array();
     509            foreach ( $this->get_dependents( $id ) as $dependent ) {
     510                $dependents = array_merge(
     511                    $dependents,
     512                    array( $dependent ),
     513                    $get( $dependent, $checked )
     514                );
     515            }
     516
     517            return $dependents;
     518        };
     519
     520        return array_unique( $get( $id ) );
    395521    }
    396522
  • trunk/src/wp-includes/class-wp-scripts.php

    r60722 r60931  
    128128     *
    129129     * @since 6.3.0
    130      * @var array
     130     * @var array<string, string[]>
    131131     */
    132132    private $dependents_map = array();
     
    440440            $attr['data-wp-strategy'] = $intended_strategy;
    441441        }
    442         if ( isset( $obj->extra['fetchpriority'] ) && 'auto' !== $obj->extra['fetchpriority'] && $this->is_valid_fetchpriority( $obj->extra['fetchpriority'] ) ) {
    443             $attr['fetchpriority'] = $obj->extra['fetchpriority'];
    444         }
     442
     443        // Determine fetchpriority.
     444        $original_fetchpriority = isset( $obj->extra['fetchpriority'] ) ? $obj->extra['fetchpriority'] : null;
     445        if ( null === $original_fetchpriority || ! $this->is_valid_fetchpriority( $original_fetchpriority ) ) {
     446            $original_fetchpriority = 'auto';
     447        }
     448        $actual_fetchpriority = $this->get_highest_fetchpriority_with_dependents( $handle );
     449        if ( null === $actual_fetchpriority ) {
     450            // If null, it's likely this script was not explicitly enqueued, so in this case use the original priority.
     451            $actual_fetchpriority = $original_fetchpriority;
     452        }
     453        if ( is_string( $actual_fetchpriority ) && 'auto' !== $actual_fetchpriority ) {
     454            $attr['fetchpriority'] = $actual_fetchpriority;
     455        }
     456        if ( $original_fetchpriority !== $actual_fetchpriority ) {
     457            $attr['data-wp-fetchpriority'] = $original_fetchpriority;
     458        }
     459
    445460        $tag  = $translations . $ie_conditional_prefix . $before_script;
    446461        $tag .= wp_get_script_tag( $attr );
     
    899914     * Gets all dependents of a script.
    900915     *
     916     * This is not recursive.
     917     *
    901918     * @since 6.3.0
    902919     *
     
    10501067
    10511068        return $eligible_strategies;
     1069    }
     1070
     1071    /**
     1072     * Gets the highest fetch priority for a given script and all of its dependent scripts.
     1073     *
     1074     * @since 6.9.0
     1075     * @see self::filter_eligible_strategies()
     1076     * @see WP_Script_Modules::get_highest_fetchpriority_with_dependents()
     1077     *
     1078     * @param string              $handle  Script module ID.
     1079     * @param array<string, true> $checked Optional. An array of already checked script handles, used to avoid recursive loops.
     1080     * @return string|null Highest fetch priority for the script and its dependents.
     1081     */
     1082    private function get_highest_fetchpriority_with_dependents( string $handle, array $checked = array() ): ?string {
     1083        // If there is a recursive dependency, return early.
     1084        if ( isset( $checked[ $handle ] ) ) {
     1085            return null;
     1086        }
     1087
     1088        // Mark this handle as checked to guard against infinite recursion.
     1089        $checked[ $handle ] = true;
     1090
     1091        // Abort if the script is not enqueued or a dependency of an enqueued script.
     1092        if ( ! $this->query( $handle, 'enqueued' ) ) {
     1093            return null;
     1094        }
     1095
     1096        $fetchpriority = $this->get_data( $handle, 'fetchpriority' );
     1097        if ( ! $this->is_valid_fetchpriority( $fetchpriority ) ) {
     1098            $fetchpriority = 'auto';
     1099        }
     1100
     1101        static $priorities   = array(
     1102            'low',
     1103            'auto',
     1104            'high',
     1105        );
     1106        $high_priority_index = count( $priorities ) - 1;
     1107
     1108        $highest_priority_index = (int) array_search( $fetchpriority, $priorities, true );
     1109        if ( $highest_priority_index !== $high_priority_index ) {
     1110            foreach ( $this->get_dependents( $handle ) as $dependent_handle ) {
     1111                $dependent_priority = $this->get_highest_fetchpriority_with_dependents( $dependent_handle, $checked );
     1112                if ( is_string( $dependent_priority ) ) {
     1113                    $highest_priority_index = max(
     1114                        $highest_priority_index,
     1115                        (int) array_search( $dependent_priority, $priorities, true )
     1116                    );
     1117                    if ( $highest_priority_index === $high_priority_index ) {
     1118                        break;
     1119                    }
     1120                }
     1121            }
     1122        }
     1123
     1124        return $priorities[ $highest_priority_index ];
    10521125    }
    10531126
  • trunk/src/wp-includes/script-modules.php

    r60704 r60931  
    182182        }
    183183
    184         // The Interactivity API is designed with server-side rendering as its primary goal, so all of its script modules should be loaded with low fetch priority since they should not be needed in the critical rendering path.
     184        /*
     185         * The Interactivity API is designed with server-side rendering as its primary goal, so all of its script modules
     186         * should be loaded with low fetchpriority since they should not be needed in the critical rendering path.
     187         * Also, the @wordpress/a11y script module is intended to be used as a dynamic import dependency, in which case
     188         * the fetchpriority is irrelevant. See <https://make.wordpress.org/core/2024/10/14/updates-to-script-modules-in-6-7/>.
     189         * However, in case it is added as a static import dependency, the fetchpriority is explicitly set to be 'low'
     190         * since the module should not be involved in the critical rendering path, and if it is, its fetchpriority will
     191         * be bumped to match the fetchpriority of the dependent script.
     192         */
    185193        $args = array();
    186         if ( str_starts_with( $script_module_id, '@wordpress/interactivity' ) || str_starts_with( $script_module_id, '@wordpress/block-library' ) ) {
     194        if (
     195            str_starts_with( $script_module_id, '@wordpress/interactivity' ) ||
     196            str_starts_with( $script_module_id, '@wordpress/block-library' ) ||
     197            '@wordpress/a11y' === $script_module_id
     198        ) {
    187199            $args['fetchpriority'] = 'low';
    188200        }
  • trunk/tests/phpunit/tests/dependencies/scripts.php

    r60729 r60931  
    12331233            )
    12341234        );
     1235        // Note: All of these scripts have fetchpriority=high because the leaf dependent script has that fetch priority.
    12351236        $output    = get_echo( 'wp_print_scripts' );
    1236         $expected  = "<script type='text/javascript' src='/main-script-d4.js' id='main-script-d4-js' defer='defer' data-wp-strategy='defer'></script>\n";
    1237         $expected .= "<script type='text/javascript' src='/dependent-script-d4-1.js' id='dependent-script-d4-1-js' defer='defer' data-wp-strategy='defer'></script>\n";
    1238         $expected .= "<script type='text/javascript' src='/dependent-script-d4-2.js' id='dependent-script-d4-2-js' defer='defer' data-wp-strategy='async' fetchpriority='low'></script>\n";
     1237        $expected  = "<script type='text/javascript' src='/main-script-d4.js'        id='main-script-d4-js'        defer='defer' data-wp-strategy='defer' fetchpriority='high' data-wp-fetchpriority='auto'></script>\n";
     1238        $expected .= "<script type='text/javascript' src='/dependent-script-d4-1.js' id='dependent-script-d4-1-js' defer='defer' data-wp-strategy='defer' fetchpriority='high' data-wp-fetchpriority='auto'></script>\n";
     1239        $expected .= "<script type='text/javascript' src='/dependent-script-d4-2.js' id='dependent-script-d4-2-js' defer='defer' data-wp-strategy='async' fetchpriority='high' data-wp-fetchpriority='low'></script>\n";
    12391240        $expected .= "<script type='text/javascript' src='/dependent-script-d4-3.js' id='dependent-script-d4-3-js' defer='defer' data-wp-strategy='defer' fetchpriority='high'></script>\n";
    12401241
     
    13421343        wp_register_script( 'alias', false, array(), null, array( 'fetchpriority' => 'low' ) );
    13431344        $this->assertArrayNotHasKey( 'fetchpriority', wp_scripts()->registered['alias']->extra );
     1345    }
     1346
     1347    /**
     1348     * Data provider.
     1349     *
     1350     * @return array<string, array{enqueues: string[], expected: string}>
     1351     */
     1352    public function data_provider_to_test_fetchpriority_bumping(): array {
     1353        return array(
     1354            'enqueue_bajo' => array(
     1355                'enqueues' => array( 'bajo' ),
     1356                'expected' => '<script fetchpriority="low" id="bajo-js" src="/bajo.js" type="text/javascript"></script>',
     1357            ),
     1358            'enqueue_auto' => array(
     1359                'enqueues' => array( 'auto' ),
     1360                'expected' => '
     1361                    <script type="text/javascript" src="/bajo.js" id="bajo-js" data-wp-fetchpriority="low"></script>
     1362                    <script type="text/javascript" src="/auto.js" id="auto-js"></script>
     1363                ',
     1364            ),
     1365            'enqueue_alto' => array(
     1366                'enqueues' => array( 'alto' ),
     1367                'expected' => '
     1368                    <script type="text/javascript" src="/bajo.js" id="bajo-js" fetchpriority="high" data-wp-fetchpriority="low"></script>
     1369                    <script type="text/javascript" src="/auto.js" id="auto-js" fetchpriority="high" data-wp-fetchpriority="auto"></script>
     1370                    <script type="text/javascript" src="/alto.js" id="alto-js" fetchpriority="high"></script>
     1371                ',
     1372            ),
     1373        );
     1374    }
     1375
     1376    /**
     1377     * Tests a higher fetchpriority on a dependent script module causes the fetchpriority of a dependency script module to be bumped.
     1378     *
     1379     * @ticket 61734
     1380     *
     1381     * @covers WP_Scripts::get_dependents
     1382     * @covers WP_Scripts::get_highest_fetchpriority_with_dependents
     1383     * @covers WP_Scripts::do_item
     1384     *
     1385     * @dataProvider data_provider_to_test_fetchpriority_bumping
     1386     */
     1387    public function test_fetchpriority_bumping( array $enqueues, string $expected ) {
     1388        wp_register_script( 'bajo', '/bajo.js', array(), null, array( 'fetchpriority' => 'low' ) );
     1389        wp_register_script( 'auto', '/auto.js', array( 'bajo' ), null, array( 'fetchpriority' => 'auto' ) );
     1390        wp_register_script( 'alto', '/alto.js', array( 'auto' ), null, array( 'fetchpriority' => 'high' ) );
     1391
     1392        foreach ( $enqueues as $enqueue ) {
     1393            wp_enqueue_script( $enqueue );
     1394        }
     1395
     1396        $actual = get_echo( 'wp_print_scripts' );
     1397        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1398    }
     1399
     1400    /**
     1401     * Tests bumping fetchpriority with complex dependency graph.
     1402     *
     1403     * @ticket 61734
     1404     * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3280065818
     1405     *
     1406     * @covers WP_Scripts::get_dependents
     1407     * @covers WP_Scripts::get_highest_fetchpriority_with_dependents
     1408     * @covers WP_Scripts::do_item
     1409     */
     1410    public function test_fetchpriority_bumping_a_to_z() {
     1411        wp_register_script( 'a', '/a.js', array( 'b' ), null, array( 'fetchpriority' => 'low' ) );
     1412        wp_register_script( 'b', '/b.js', array( 'c' ), null, array( 'fetchpriority' => 'auto' ) );
     1413        wp_register_script( 'c', '/c.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'auto' ) );
     1414        wp_register_script( 'd', '/d.js', array( 'z' ), null, array( 'fetchpriority' => 'high' ) );
     1415        wp_register_script( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'auto' ) );
     1416
     1417        wp_register_script( 'x', '/x.js', array( 'd', 'y' ), null, array( 'fetchpriority' => 'high' ) );
     1418        wp_register_script( 'y', '/y.js', array( 'z' ), null, array( 'fetchpriority' => 'auto' ) );
     1419        wp_register_script( 'z', '/z.js', array(), null, array( 'fetchpriority' => 'auto' ) );
     1420
     1421        wp_enqueue_script( 'a' );
     1422        wp_enqueue_script( 'x' );
     1423
     1424        $actual   = get_echo( 'wp_print_scripts' );
     1425        $expected = '
     1426            <script type="text/javascript" src="/z.js" id="z-js" fetchpriority="high" data-wp-fetchpriority="auto"></script>
     1427            <script type="text/javascript" src="/d.js" id="d-js" fetchpriority="high"></script>
     1428            <script type="text/javascript" src="/e.js" id="e-js"></script>
     1429            <script type="text/javascript" src="/c.js" id="c-js"></script>
     1430            <script type="text/javascript" src="/b.js" id="b-js"></script>
     1431            <script type="text/javascript" src="/a.js" id="a-js" fetchpriority="low"></script>
     1432            <script type="text/javascript" src="/y.js" id="y-js" fetchpriority="high" data-wp-fetchpriority="auto"></script>
     1433            <script type="text/javascript" src="/x.js" id="x-js" fetchpriority="high"></script>
     1434        ';
     1435        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1436    }
     1437
     1438    /**
     1439     * Tests that printing a script without enqueueing has the same output as when it is enqueued.
     1440     *
     1441     * @ticket 61734
     1442     *
     1443     * @covers WP_Scripts::do_item
     1444     * @covers WP_Scripts::do_items
     1445     * @covers ::wp_default_scripts
     1446     *
     1447     * @dataProvider data_provider_enqueue_or_not_to_enqueue
     1448     */
     1449    public function test_printing_default_script_comment_reply_enqueued_or_not_enqueued( bool $enqueue ) {
     1450        $wp_scripts = wp_scripts();
     1451        wp_default_scripts( $wp_scripts );
     1452
     1453        $this->assertArrayHasKey( 'comment-reply', $wp_scripts->registered );
     1454        $wp_scripts->registered['comment-reply']->ver = null;
     1455        $this->assertArrayHasKey( 'fetchpriority', $wp_scripts->registered['comment-reply']->extra );
     1456        $this->assertSame( 'low', $wp_scripts->registered['comment-reply']->extra['fetchpriority'] );
     1457        $this->assertArrayHasKey( 'strategy', $wp_scripts->registered['comment-reply']->extra );
     1458        $this->assertSame( 'async', $wp_scripts->registered['comment-reply']->extra['strategy'] );
     1459        if ( $enqueue ) {
     1460            wp_enqueue_script( 'comment-reply' );
     1461            $markup = get_echo( array( $wp_scripts, 'do_items' ), array( false ) );
     1462        } else {
     1463            $markup = get_echo( array( $wp_scripts, 'do_items' ), array( array( 'comment-reply' ) ) );
     1464        }
     1465
     1466        $this->assertEqualHTML(
     1467            sprintf(
     1468                '<script type="text/javascript" src="%s" id="comment-reply-js" async="async" data-wp-strategy="async" fetchpriority="low"></script>',
     1469                includes_url( 'js/comment-reply.js' )
     1470            ),
     1471            $markup
     1472        );
     1473    }
     1474
     1475    /**
     1476     * Data provider for test_default_scripts_comment_reply_not_enqueued.
     1477     *
     1478     * @return array[]
     1479     */
     1480    public static function data_provider_enqueue_or_not_to_enqueue(): array {
     1481        return array(
     1482            'not_enqueued' => array(
     1483                false,
     1484            ),
     1485            'enqueued'     => array(
     1486                true,
     1487            ),
     1488        );
    13441489    }
    13451490
  • trunk/tests/phpunit/tests/script-modules/wpScriptModules.php

    r60930 r60931  
    6868            $id             = preg_replace( '/-js-module$/', '', (string) $p->get_attribute( 'id' ) );
    6969            $fetchpriority  = $p->get_attribute( 'fetchpriority' );
    70             $modules[ $id ] = array(
    71                 'url'           => $p->get_attribute( 'src' ),
    72                 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto',
     70            $modules[ $id ] = array_merge(
     71                array(
     72                    'url'           => $p->get_attribute( 'src' ),
     73                    'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto',
     74                ),
     75                ...array_map(
     76                    static function ( $attribute_name ) use ( $p ) {
     77                        return array( $attribute_name => $p->get_attribute( $attribute_name ) );
     78                    },
     79                    $p->get_attribute_names_with_prefix( 'data-' )
     80                )
    7381            );
    7482        }
     
    113121            $id              = preg_replace( '/-js-modulepreload$/', '', $p->get_attribute( 'id' ) );
    114122            $fetchpriority   = $p->get_attribute( 'fetchpriority' );
    115             $preloads[ $id ] = array(
    116                 'url'           => $p->get_attribute( 'href' ),
    117                 'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto',
     123            $preloads[ $id ] = array_merge(
     124                array(
     125                    'url'           => $p->get_attribute( 'href' ),
     126                    'fetchpriority' => is_string( $fetchpriority ) ? $fetchpriority : 'auto',
     127                ),
     128                ...array_map(
     129                    static function ( $attribute_name ) use ( $p ) {
     130                        return array( $attribute_name => $p->get_attribute( $attribute_name ) );
     131                    },
     132                    $p->get_attribute_names_with_prefix( 'data-' )
     133                )
    118134            );
    119135        }
     
    272288                    'b-dep'        => array(
    273289                        'url'           => '/b-dep.js?ver=99.9.9',
    274                         'fetchpriority' => 'auto',
     290                        'fetchpriority' => 'low', // Propagates from 'b'.
    275291                    ),
    276292                    'c-dep'        => array(
    277                         'url'           => '/c-static.js?ver=99.9.9',
    278                         'fetchpriority' => 'low',
     293                        'url'                   => '/c-static.js?ver=99.9.9',
     294                        'fetchpriority'         => 'auto', // Not 'low' because the dependent script 'c' has a fetchpriority of 'auto'.
     295                        'data-wp-fetchpriority' => 'low',
    279296                    ),
    280297                    'c-static-dep' => array(
    281                         'url'           => '/c-static-dep.js?ver=99.9.9',
    282                         'fetchpriority' => 'high',
     298                        'url'                   => '/c-static-dep.js?ver=99.9.9',
     299                        'fetchpriority'         => 'auto', // Propagated from 'c'.
     300                        'data-wp-fetchpriority' => 'high',
    283301                    ),
    284302                    'd-static-dep' => array(
     
    733751                ),
    734752            )
     753            // Note: The default fetchpriority=auto is upgraded to high because the dependent script module 'static-dep' has a high fetch priority.
    735754        );
    736755        $this->script_modules->register(
     
    760779        $this->assertCount( 2, $preloaded_script_modules );
    761780        $this->assertStringStartsWith( '/static-dep.js', $preloaded_script_modules['static-dep']['url'] );
    762         $this->assertSame( 'high', $preloaded_script_modules['static-dep']['fetchpriority'] );
     781        $this->assertSame( 'auto', $preloaded_script_modules['static-dep']['fetchpriority'] ); // Not 'high'
    763782        $this->assertStringStartsWith( '/nested-static-dep.js', $preloaded_script_modules['nested-static-dep']['url'] );
    764         $this->assertSame( 'auto', $preloaded_script_modules['nested-static-dep']['fetchpriority'] );
     783        $this->assertSame( 'auto', $preloaded_script_modules['nested-static-dep']['fetchpriority'] ); // Auto because the enqueued script foo has the fetchpriority of auto.
    765784        $this->assertArrayNotHasKey( 'dynamic-dep', $preloaded_script_modules );
    766785        $this->assertArrayNotHasKey( 'nested-dynamic-dep', $preloaded_script_modules );
     
    972991        $preloaded_script_modules = $this->get_preloaded_script_modules();
    973992        $this->assertSame( '/dep.js?ver=2.0', $preloaded_script_modules['dep']['url'] );
    974         $this->assertSame( 'high', $preloaded_script_modules['dep']['fetchpriority'] );
     993        $this->assertSame( 'auto', $preloaded_script_modules['dep']['fetchpriority'] ); // Because 'foo' has a priority of 'auto'.
    975994    }
    976995
     
    13451364
    13461365    /**
     1366     * Data provider.
     1367     *
     1368     * @return array<string, array{enqueues: string[], expected: array}>
     1369     */
     1370    public function data_provider_to_test_fetchpriority_bumping(): array {
     1371        return array(
     1372            'enqueue_bajo' => array(
     1373                'enqueues' => array( 'bajo' ),
     1374                'expected' => array(
     1375                    'preload_links' => array(),
     1376                    'script_tags'   => array(
     1377                        'bajo' => array(
     1378                            'url'                   => '/bajo.js',
     1379                            'fetchpriority'         => 'high',
     1380                            'data-wp-fetchpriority' => 'low',
     1381                        ),
     1382                    ),
     1383                    'import_map'    => array(
     1384                        'dyno' => '/dyno.js',
     1385                    ),
     1386                ),
     1387            ),
     1388            'enqueue_auto' => array(
     1389                'enqueues' => array( 'auto' ),
     1390                'expected' => array(
     1391                    'preload_links' => array(
     1392                        'bajo' => array(
     1393                            'url'                   => '/bajo.js',
     1394                            'fetchpriority'         => 'auto',
     1395                            'data-wp-fetchpriority' => 'low',
     1396                        ),
     1397                    ),
     1398                    'script_tags'   => array(
     1399                        'auto' => array(
     1400                            'url'                   => '/auto.js',
     1401                            'fetchpriority'         => 'high',
     1402                            'data-wp-fetchpriority' => 'auto',
     1403                        ),
     1404                    ),
     1405                    'import_map'    => array(
     1406                        'bajo' => '/bajo.js',
     1407                        'dyno' => '/dyno.js',
     1408                    ),
     1409                ),
     1410            ),
     1411            'enqueue_alto' => array(
     1412                'enqueues' => array( 'alto' ),
     1413                'expected' => array(
     1414                    'preload_links' => array(
     1415                        'auto' => array(
     1416                            'url'           => '/auto.js',
     1417                            'fetchpriority' => 'high',
     1418                        ),
     1419                        'bajo' => array(
     1420                            'url'                   => '/bajo.js',
     1421                            'fetchpriority'         => 'high',
     1422                            'data-wp-fetchpriority' => 'low',
     1423                        ),
     1424                    ),
     1425                    'script_tags'   => array(
     1426                        'alto' => array(
     1427                            'url'           => '/alto.js',
     1428                            'fetchpriority' => 'high',
     1429                        ),
     1430                    ),
     1431                    'import_map'    => array(
     1432                        'auto' => '/auto.js',
     1433                        'bajo' => '/bajo.js',
     1434                        'dyno' => '/dyno.js',
     1435                    ),
     1436                ),
     1437            ),
     1438        );
     1439    }
     1440
     1441    /**
     1442     * Tests a higher fetchpriority on a dependent script module causes the fetchpriority of a dependency script module to be bumped.
     1443     *
     1444     * @ticket 61734
     1445     *
     1446     * @covers WP_Script_Modules::print_enqueued_script_modules
     1447     * @covers WP_Script_Modules::get_dependents
     1448     * @covers WP_Script_Modules::get_recursive_dependents
     1449     * @covers WP_Script_Modules::get_highest_fetchpriority
     1450     * @covers WP_Script_Modules::print_script_module_preloads
     1451     *
     1452     * @dataProvider data_provider_to_test_fetchpriority_bumping
     1453     */
     1454    public function test_fetchpriority_bumping( array $enqueues, array $expected ) {
     1455        $this->script_modules->register(
     1456            'dyno',
     1457            '/dyno.js',
     1458            array(),
     1459            null,
     1460            array( 'fetchpriority' => 'low' ) // This won't show up anywhere since it is a dynamic import dependency.
     1461        );
     1462
     1463        $this->script_modules->register(
     1464            'bajo',
     1465            '/bajo.js',
     1466            array(
     1467                array(
     1468                    'id'     => 'dyno',
     1469                    'import' => 'dynamic',
     1470                ),
     1471            ),
     1472            null,
     1473            array( 'fetchpriority' => 'low' )
     1474        );
     1475
     1476        $this->script_modules->register(
     1477            'auto',
     1478            '/auto.js',
     1479            array(
     1480                array(
     1481                    'id'     => 'bajo',
     1482                    'import' => 'static',
     1483                ),
     1484            ),
     1485            null,
     1486            array( 'fetchpriority' => 'auto' )
     1487        );
     1488        $this->script_modules->register(
     1489            'alto',
     1490            '/alto.js',
     1491            array( 'auto' ),
     1492            null,
     1493            array( 'fetchpriority' => 'high' )
     1494        );
     1495
     1496        foreach ( $enqueues as $enqueue ) {
     1497            $this->script_modules->enqueue( $enqueue );
     1498        }
     1499
     1500        $actual = array(
     1501            'preload_links' => $this->get_preloaded_script_modules(),
     1502            'script_tags'   => $this->get_enqueued_script_modules(),
     1503            'import_map'    => $this->get_import_map(),
     1504        );
     1505        $this->assertSame(
     1506            $expected,
     1507            $actual,
     1508            "Snapshot:\n" . var_export( $actual, true )
     1509        );
     1510    }
     1511
     1512    /**
     1513     * Tests bumping fetchpriority with complex dependency graph.
     1514     *
     1515     * @ticket 61734
     1516     * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3280065818
     1517     *
     1518     * @covers WP_Script_Modules::print_enqueued_script_modules
     1519     * @covers WP_Script_Modules::get_dependents
     1520     * @covers WP_Script_Modules::get_recursive_dependents
     1521     * @covers WP_Script_Modules::get_highest_fetchpriority
     1522     * @covers WP_Script_Modules::print_script_module_preloads
     1523     */
     1524    public function test_fetchpriority_bumping_a_to_z() {
     1525        wp_register_script_module( 'a', '/a.js', array( 'b' ), null, array( 'fetchpriority' => 'low' ) );
     1526        wp_register_script_module( 'b', '/b.js', array( 'c' ), null, array( 'fetchpriority' => 'auto' ) );
     1527        wp_register_script_module( 'c', '/c.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'auto' ) );
     1528        wp_register_script_module( 'd', '/d.js', array( 'z' ), null, array( 'fetchpriority' => 'high' ) );
     1529        wp_register_script_module( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'auto' ) );
     1530
     1531        wp_register_script_module( 'x', '/x.js', array( 'd', 'y' ), null, array( 'fetchpriority' => 'high' ) );
     1532        wp_register_script_module( 'y', '/y.js', array( 'z' ), null, array( 'fetchpriority' => 'auto' ) );
     1533        wp_register_script_module( 'z', '/z.js', array(), null, array( 'fetchpriority' => 'auto' ) );
     1534
     1535        // The fetch priorities are derived from these enqueued dependents.
     1536        wp_enqueue_script_module( 'a' );
     1537        wp_enqueue_script_module( 'x' );
     1538
     1539        $actual   = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) );
     1540        $actual  .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) );
     1541        $expected = '
     1542            <link rel="modulepreload" href="/b.js" id="b-js-modulepreload" fetchpriority="low">
     1543            <link rel="modulepreload" href="/c.js" id="c-js-modulepreload" fetchpriority="low">
     1544            <link rel="modulepreload" href="/d.js" id="d-js-modulepreload" fetchpriority="high">
     1545            <link rel="modulepreload" href="/e.js" id="e-js-modulepreload" fetchpriority="low">
     1546            <link rel="modulepreload" href="/z.js" id="z-js-modulepreload" fetchpriority="high">
     1547            <link rel="modulepreload" href="/y.js" id="y-js-modulepreload" fetchpriority="high">
     1548            <script type="module" src="/a.js" id="a-js-module" fetchpriority="low"></script>
     1549            <script type="module" src="/x.js" id="x-js-module" fetchpriority="high"></script>
     1550        ';
     1551        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1552    }
     1553
     1554    /**
     1555     * Tests bumping fetchpriority with complex dependency graph.
     1556     *
     1557     * @ticket 61734
     1558     * @link https://github.com/WordPress/wordpress-develop/pull/9770#issuecomment-3284266884
     1559     *
     1560     * @covers WP_Script_Modules::print_enqueued_script_modules
     1561     * @covers WP_Script_Modules::get_dependents
     1562     * @covers WP_Script_Modules::get_recursive_dependents
     1563     * @covers WP_Script_Modules::get_highest_fetchpriority
     1564     * @covers WP_Script_Modules::print_script_module_preloads
     1565     */
     1566    public function test_fetchpriority_propagation() {
     1567        // The high fetchpriority for this module will be disregarded because its enqueued dependent has a non-high priority.
     1568        wp_register_script_module( 'a', '/a.js', array( 'd', 'e' ), null, array( 'fetchpriority' => 'high' ) );
     1569        wp_register_script_module( 'b', '/b.js', array( 'e' ), null );
     1570        wp_register_script_module( 'c', '/c.js', array( 'e', 'f' ), null );
     1571        wp_register_script_module( 'd', '/d.js', array(), null );
     1572        // The low fetchpriority for this module will be disregarded because its enqueued dependent has a non-low priority.
     1573        wp_register_script_module( 'e', '/e.js', array(), null, array( 'fetchpriority' => 'low' ) );
     1574        wp_register_script_module( 'f', '/f.js', array(), null );
     1575
     1576        wp_register_script_module( 'x', '/x.js', array( 'a' ), null, array( 'fetchpriority' => 'low' ) );
     1577        wp_register_script_module( 'y', '/y.js', array( 'b' ), null, array( 'fetchpriority' => 'auto' ) );
     1578        wp_register_script_module( 'z', '/z.js', array( 'c' ), null, array( 'fetchpriority' => 'high' ) );
     1579
     1580        wp_enqueue_script_module( 'x' );
     1581        wp_enqueue_script_module( 'y' );
     1582        wp_enqueue_script_module( 'z' );
     1583
     1584        $actual   = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) );
     1585        $actual  .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) );
     1586        $expected = '
     1587            <link rel="modulepreload" href="/a.js" id="a-js-modulepreload" fetchpriority="low" data-wp-fetchpriority="high">
     1588            <link rel="modulepreload" href="/d.js" id="d-js-modulepreload" fetchpriority="low">
     1589            <link rel="modulepreload" href="/e.js" id="e-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="low">
     1590            <link rel="modulepreload" href="/b.js" id="b-js-modulepreload">
     1591            <link rel="modulepreload" href="/c.js" id="c-js-modulepreload" fetchpriority="high">
     1592            <link rel="modulepreload" href="/f.js" id="f-js-modulepreload" fetchpriority="high">
     1593            <script type="module" src="/x.js" id="x-js-module" fetchpriority="low"></script>
     1594            <script type="module" src="/y.js" id="y-js-module"></script>
     1595            <script type="module" src="/z.js" id="z-js-module" fetchpriority="high"></script>
     1596        ';
     1597        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1598    }
     1599
     1600    /**
     1601     * Tests that default script modules are printed as expected.
     1602     *
     1603     * @covers ::wp_default_script_modules
     1604     * @covers WP_Script_Modules::print_script_module_preloads
     1605     * @covers WP_Script_Modules::print_enqueued_script_modules
     1606     */
     1607    public function test_default_script_modules() {
     1608        wp_default_script_modules();
     1609        wp_enqueue_script_module( '@wordpress/a11y' );
     1610        wp_enqueue_script_module( '@wordpress/block-library/navigation/view' );
     1611
     1612        $actual  = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) );
     1613        $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) );
     1614
     1615        $actual = $this->normalize_markup_for_snapshot( $actual );
     1616
     1617        $expected = '
     1618            <link rel="modulepreload" href="/wp-includes/js/dist/script-modules/interactivity/debug.min.js" id="@wordpress/interactivity-js-modulepreload" fetchpriority="low">
     1619            <script type="module" src="/wp-includes/js/dist/script-modules/a11y/index.min.js" id="@wordpress/a11y-js-module" fetchpriority="low"></script>
     1620            <script type="module" src="/wp-includes/js/dist/script-modules/block-library/navigation/view.min.js" id="@wordpress/block-library/navigation/view-js-module" fetchpriority="low"></script>
     1621        ';
     1622        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1623    }
     1624
     1625    /**
     1626     * Tests that a dependent with high priority for default script modules with a low fetch priority are printed as expected.
     1627     *
     1628     * @covers ::wp_default_script_modules
     1629     * @covers WP_Script_Modules::print_script_module_preloads
     1630     * @covers WP_Script_Modules::print_enqueued_script_modules
     1631     */
     1632    public function test_dependent_of_default_script_modules() {
     1633        wp_default_script_modules();
     1634        wp_enqueue_script_module(
     1635            'super-important',
     1636            '/super-important-module.js',
     1637            array( '@wordpress/a11y', '@wordpress/block-library/navigation/view' ),
     1638            null,
     1639            array( 'fetchpriority' => 'high' )
     1640        );
     1641
     1642        $actual  = get_echo( array( wp_script_modules(), 'print_script_module_preloads' ) );
     1643        $actual .= get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) );
     1644
     1645        $actual = $this->normalize_markup_for_snapshot( $actual );
     1646
     1647        $expected = '
     1648            <link rel="modulepreload" href="/wp-includes/js/dist/script-modules/a11y/index.min.js" id="@wordpress/a11y-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="low">
     1649            <link rel="modulepreload" href="/wp-includes/js/dist/script-modules/block-library/navigation/view.min.js" id="@wordpress/block-library/navigation/view-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="low">
     1650            <link rel="modulepreload" href="/wp-includes/js/dist/script-modules/interactivity/debug.min.js" id="@wordpress/interactivity-js-modulepreload" fetchpriority="high" data-wp-fetchpriority="low">
     1651            <script type="module" src="/super-important-module.js" id="super-important-js-module" fetchpriority="high"></script>
     1652        ';
     1653        $this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
     1654    }
     1655
     1656    /**
     1657     * Normalizes markup for snapshot.
     1658     *
     1659     * @param string $markup Markup.
     1660     * @return string Normalized markup.
     1661     */
     1662    private function normalize_markup_for_snapshot( string $markup ): string {
     1663        $processor = new WP_HTML_Tag_Processor( $markup );
     1664        $clean_url = static function ( string $url ): string {
     1665            $url = preg_replace( '#^https?://[^/]+#', '', $url );
     1666            return remove_query_arg( 'ver', $url );
     1667        };
     1668        while ( $processor->next_tag() ) {
     1669            if ( 'LINK' === $processor->get_tag() && is_string( $processor->get_attribute( 'href' ) ) ) {
     1670                $processor->set_attribute( 'href', $clean_url( $processor->get_attribute( 'href' ) ) );
     1671            } elseif ( 'SCRIPT' === $processor->get_tag() && is_string( $processor->get_attribute( 'src' ) ) ) {
     1672                $processor->set_attribute( 'src', $clean_url( $processor->get_attribute( 'src' ) ) );
     1673            }
     1674        }
     1675        return $processor->get_updated_html();
     1676    }
     1677
     1678    /**
    13471679     * Tests that directly manipulating the queue works as expected.
    13481680     *
Note: See TracChangeset for help on using the changeset viewer.