Make WordPress Core

Changeset 61176


Ignore:
Timestamp:
11/07/2025 06:11:24 AM (2 weeks ago)
Author:
westonruter
Message:

Script Loader: Guard against exponential recursion during calculation of loading strategy and fetchpriority.

This addresses a performance issue in the recursive WP_Scripts::get_highest_fetchpriority_with_dependents() and WP_Scripts::filter_eligible_strategies() methods for redundant processing of shared dependencies in complex dependency graphs. To fix this, a $stored_results param is introduced which is passed by reference; this variable contains a cache of the calculated results for all scripts handles, so that subsequent calls for the same handle can return the cached value instead of re-computing it.

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

Follow-up to [60704], [60931], [56033].

Props ciobanucatalin, b1ink0, westonruter, mukesh27.
See #61734, #12009.
Fixes #64194.

Location:
trunk
Files:
2 edited

Legend:

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

    r60948 r61176  
    998998     * @since 6.3.0
    999999     *
    1000      * @param string              $handle              The script handle.
    1001      * @param string[]|null       $eligible_strategies Optional. The list of strategies to filter. Default null.
    1002      * @param array<string, true> $checked             Optional. An array of already checked script handles, used to avoid recursive loops.
     1000     * @param string                  $handle              The script handle.
     1001     * @param string[]|null           $eligible_strategies Optional. The list of strategies to filter. Default null.
     1002     * @param array<string, true>     $checked             Optional. An array of already checked script handles, used to avoid recursive loops.
     1003     * @param array<string, string[]> $stored_results      Optional. An array of already computed eligible loading strategies by handle, used to increase performance in large dependency lists.
    10031004     * @return string[] A list of eligible loading strategies that could be used.
    10041005     */
    1005     private function filter_eligible_strategies( $handle, $eligible_strategies = null, $checked = array() ) {
     1006    private function filter_eligible_strategies( $handle, $eligible_strategies = null, $checked = array(), array &$stored_results = array() ) {
     1007        if ( isset( $stored_results[ $handle ] ) ) {
     1008            return $stored_results[ $handle ];
     1009        }
     1010
    10061011        // If no strategies are being passed, all strategies are eligible.
    10071012        if ( null === $eligible_strategies ) {
     
    10541059            }
    10551060
    1056             $eligible_strategies = $this->filter_eligible_strategies( $dependent, $eligible_strategies, $checked );
    1057         }
    1058 
     1061            $eligible_strategies = $this->filter_eligible_strategies( $dependent, $eligible_strategies, $checked, $stored_results );
     1062        }
     1063        $stored_results[ $handle ] = $eligible_strategies;
    10591064        return $eligible_strategies;
    10601065    }
     
    10671072     * @see WP_Script_Modules::get_highest_fetchpriority_with_dependents()
    10681073     *
    1069      * @param string              $handle  Script module ID.
    1070      * @param array<string, true> $checked Optional. An array of already checked script handles, used to avoid recursive loops.
     1074     * @param string                $handle         Script module ID.
     1075     * @param array<string, true>   $checked        Optional. An array of already checked script handles, used to avoid recursive loops.
     1076     * @param array<string, string> $stored_results Optional. An array of already computed max priority by handle, used to increase performance in large dependency lists.
    10711077     * @return string|null Highest fetch priority for the script and its dependents.
    10721078     */
    1073     private function get_highest_fetchpriority_with_dependents( string $handle, array $checked = array() ): ?string {
     1079    private function get_highest_fetchpriority_with_dependents( string $handle, array $checked = array(), array &$stored_results = array() ): ?string {
     1080        if ( isset( $stored_results[ $handle ] ) ) {
     1081            return $stored_results[ $handle ];
     1082        }
     1083
    10741084        // If there is a recursive dependency, return early.
    10751085        if ( isset( $checked[ $handle ] ) ) {
     
    11001110        if ( $highest_priority_index !== $high_priority_index ) {
    11011111            foreach ( $this->get_dependents( $handle ) as $dependent_handle ) {
    1102                 $dependent_priority = $this->get_highest_fetchpriority_with_dependents( $dependent_handle, $checked );
     1112                $dependent_priority = $this->get_highest_fetchpriority_with_dependents( $dependent_handle, $checked, $stored_results );
    11031113                if ( is_string( $dependent_priority ) ) {
    11041114                    $highest_priority_index = max(
     
    11121122            }
    11131123        }
    1114 
     1124        $stored_results[ $handle ] = $priorities[ $highest_priority_index ]; // @phpstan-ignore parameterByRef.type (We know the index is valid and that this will be a string.)
    11151125        return $priorities[ $highest_priority_index ];
    11161126    }
  • trunk/tests/phpunit/tests/dependencies/scripts.php

    r61006 r61176  
    14371437
    14381438    /**
     1439     * Tests that `WP_Scripts::get_highest_fetchpriority_with_dependents()` correctly reuses cached results.
     1440     *
     1441     * @ticket 64194
     1442     *
     1443     * @covers WP_Scripts::get_highest_fetchpriority_with_dependents
     1444     */
     1445    public function test_highest_fetchpriority_with_dependents_uses_cached_result() {
     1446        $wp_scripts = new WP_Scripts();
     1447        $wp_scripts->add( 'd', 'https://example.com/d.js' );
     1448        $wp_scripts->add_data( 'd', 'fetchpriority', 'low' );
     1449
     1450        /*
     1451         * Simulate a pre-existing `$stored_results` cache entry for `d`.
     1452         * If the caching logic works, the function should use this "high" value
     1453         * instead of recalculating based on the actual (lower) value.
     1454         */
     1455        $stored_results = array( 'd' => 'high' );
     1456
     1457        // Access the private method using reflection.
     1458        $method = new ReflectionMethod( WP_Scripts::class, 'get_highest_fetchpriority_with_dependents' );
     1459        if ( PHP_VERSION_ID < 80100 ) {
     1460            $method->setAccessible( true );
     1461        }
     1462
     1463        // Pass `$stored_results` BY REFERENCE.
     1464        $result = $method->invokeArgs( $wp_scripts, array( 'd', array(), &$stored_results ) );
     1465
     1466        $this->assertSame(
     1467            'high',
     1468            $result,
     1469            'Expected "high" indicates that the cached `$stored_results` entry for D was used instead of recalculating.'
     1470        );
     1471    }
     1472
     1473    /**
    14391474     * Tests that printing a script without enqueueing has the same output as when it is enqueued.
    14401475     *
     
    15331568        $expected = str_replace( "'", '"', $expected );
    15341569        $this->assertSame( $expected, $output, 'Scripts registered with no strategy assigned, and who have no dependencies, should have no loading strategy attributes printed.' );
     1570    }
     1571
     1572    /**
     1573     * Tests that `WP_Scripts::filter_eligible_strategies()` correctly reuses cached results.
     1574     *
     1575     * @ticket 64194
     1576     *
     1577     * @covers WP_Scripts::filter_eligible_strategies
     1578     */
     1579    public function test_filter_eligible_strategies_uses_cached_result() {
     1580        $wp_scripts = new WP_Scripts();
     1581        $wp_scripts->add( 'd', 'https://example.com/d.js' );
     1582        $wp_scripts->add_data( 'd', 'strategy', 'defer' );
     1583
     1584        /*
     1585         * Simulate a cached result in `$stored_results` for D.
     1586         * If caching logic is functioning properly, this cached value
     1587         * should be returned immediately without recomputing.
     1588         */
     1589        $stored_results = array( 'd' => array( 'async' ) );
     1590
     1591        // Access the private method via reflection.
     1592        $method = new ReflectionMethod( WP_Scripts::class, 'filter_eligible_strategies' );
     1593        if ( PHP_VERSION_ID < 80100 ) {
     1594            $method->setAccessible( true );
     1595        }
     1596
     1597        // Invoke the method with `$stored_results` passed by reference.
     1598        $result = $method->invokeArgs( $wp_scripts, array( 'd', null, array(), &$stored_results ) );
     1599
     1600        $this->assertSame(
     1601            array( 'async' ),
     1602            $result,
     1603            'Expected cached `$stored_results` value for D to be reused instead of recomputed.'
     1604        );
    15351605    }
    15361606
Note: See TracChangeset for help on using the changeset viewer.