Skip to content

Commit de43a57

Browse files
committed
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 #10459 Follow-up to [60704], [60931], [56033]. Props ciobanucatalin, b1ink0, westonruter, mukesh27. See #61734, #12009. Fixes #64194. git-svn-id: https://develop.svn.wordpress.org/trunk@61176 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 6680408 commit de43a57

File tree

2 files changed

+91
-11
lines changed

2 files changed

+91
-11
lines changed

src/wp-includes/class-wp-scripts.php

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -997,12 +997,17 @@ private function get_eligible_loading_strategy( $handle ) {
997997
*
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 ) {
10081013
$eligible_strategies = $this->delayed_strategies;
@@ -1053,9 +1058,9 @@ private function filter_eligible_strategies( $handle, $eligible_strategies = nul
10531058
return array();
10541059
}
10551060

1056-
$eligible_strategies = $this->filter_eligible_strategies( $dependent, $eligible_strategies, $checked );
1061+
$eligible_strategies = $this->filter_eligible_strategies( $dependent, $eligible_strategies, $checked, $stored_results );
10571062
}
1058-
1063+
$stored_results[ $handle ] = $eligible_strategies;
10591064
return $eligible_strategies;
10601065
}
10611066

@@ -1066,11 +1071,16 @@ private function filter_eligible_strategies( $handle, $eligible_strategies = nul
10661071
* @see self::filter_eligible_strategies()
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 ] ) ) {
10761086
return null;
@@ -1099,7 +1109,7 @@ private function get_highest_fetchpriority_with_dependents( string $handle, arra
10991109
$highest_priority_index = (int) array_search( $fetchpriority, $priorities, true );
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(
11051115
$highest_priority_index,
@@ -1111,7 +1121,7 @@ private function get_highest_fetchpriority_with_dependents( string $handle, arra
11111121
}
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
}
11171127

tests/phpunit/tests/dependencies/scripts.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,41 @@ public function test_fetchpriority_bumping_a_to_z() {
14351435
$this->assertEqualHTML( $expected, $actual, '<body>', "Snapshot:\n$actual" );
14361436
}
14371437

1438+
/**
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+
14381473
/**
14391474
* Tests that printing a script without enqueueing has the same output as when it is enqueued.
14401475
*
@@ -1534,6 +1569,41 @@ public function test_loading_strategy_with_valid_blocking_registration() {
15341569
$this->assertSame( $expected, $output, 'Scripts registered with no strategy assigned, and who have no dependencies, should have no loading strategy attributes printed.' );
15351570
}
15361571

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+
);
1605+
}
1606+
15371607
/**
15381608
* Tests that scripts registered for the head do indeed end up there.
15391609
*

0 commit comments

Comments
 (0)