From c2409fe80427b30aa9716e573329cbd081819829 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 13 Oct 2023 18:44:12 +0000 Subject: [PATCH] Script Loader: Move delayed head script to footer when there is a blocking footer dependent. This prevents a performance regression when a blocking script is enqueued in the footer which depends on a delayed script in the `head` (with `async` or `defer`). In order to preserve the execution order, a delayed dependency must fall back to blocking when there is a blocking dependent. But since it was originally delayed (and thus executes similarly to a footer script), it does not need to be in the head and can be moved to the footer. This prevents blocking the critical rendering path. Props adamsilverstein, westonruter, flixos90. Fixes #59599. See #12009. git-svn-id: https://develop.svn.wordpress.org/trunk@56933 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-scripts.php | 38 +++ tests/phpunit/tests/dependencies/scripts.php | 287 +++++++++++++++++++ 2 files changed, 325 insertions(+) diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index a6a283f953..116e98f673 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -231,6 +231,25 @@ class WP_Scripts extends WP_Dependencies { return true; } + /** + * Checks whether all dependents of a given handle are in the footer. + * + * If there are no dependents, this is considered the same as if all dependents were in the footer. + * + * @since 6.4.0 + * + * @param string $handle Script handle. + * @return bool Whether all dependents are in the footer. + */ + private function are_all_dependents_in_footer( $handle ) { + foreach ( $this->get_dependents( $handle ) as $dep ) { + if ( isset( $this->groups[ $dep ] ) && 0 === $this->groups[ $dep ] ) { + return false; + } + } + return true; + } + /** * Processes a script dependency. * @@ -281,6 +300,25 @@ class WP_Scripts extends WP_Dependencies { $intended_strategy = ''; } + /* + * Move this script to the footer if: + * 1. The script is in the header group. + * 2. The current output is the header. + * 3. The intended strategy is delayed. + * 4. The actual strategy is not delayed. + * 5. All dependent scripts are in the footer. + */ + if ( + 0 === $group && + 0 === $this->groups[ $handle ] && + $intended_strategy && + ! $this->is_delayed_strategy( $strategy ) && + $this->are_all_dependents_in_footer( $handle ) + ) { + $this->in_footer[] = $handle; + return false; + } + if ( $conditional ) { $cond_before = "\n"; diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 7f2b956127..95cf958d3e 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -3065,4 +3065,291 @@ HTML protected function add_html5_script_theme_support() { add_theme_support( 'html5', array( 'script' ) ); } + + /** + * Test that a script is moved to the footer if it is made non-deferrable, was in the header and + * all scripts that depend on it are in the footer. + * + * @ticket 58599 + * + * @dataProvider data_provider_script_move_to_footer + * + * @param callable $set_up Test setup. + * @param string $expected_header Expected output for header. + * @param string $expected_footer Expected output for footer. + * @param string[] $expected_in_footer Handles expected to be in the footer. + * @param array $expected_groups Expected groups. + */ + public function test_wp_scripts_move_to_footer( $set_up, $expected_header, $expected_footer, $expected_in_footer, $expected_groups ) { + $set_up(); + + // Get the header output. + ob_start(); + wp_scripts()->do_head_items(); + $header = ob_get_clean(); + + // Print a script in the body just to make sure it doesn't cause problems. + ob_start(); + wp_print_scripts( array( 'jquery' ) ); + ob_end_clean(); + + // Get the footer output. + ob_start(); + wp_scripts()->do_footer_items(); + $footer = ob_get_clean(); + + $this->assertEqualMarkup( $expected_header, $header, 'Expected header script markup to match.' ); + $this->assertEqualMarkup( $expected_footer, $footer, 'Expected footer script markup to match.' ); + $this->assertEqualSets( $expected_in_footer, wp_scripts()->in_footer, 'Expected to have the same handles for in_footer.' ); + $this->assertEquals( $expected_groups, wp_scripts()->groups, 'Expected groups to match.' ); + } + + /** + * Data provider for test_wp_scripts_move_to_footer. + * + * @return array[] + */ + public function data_provider_script_move_to_footer() { + return array( + 'footer-blocking-dependent-of-defer-head-script' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( 'script-b', 'https://example.com/script-b.js', array( 'script-a' ), null, array( 'in_footer' => true ) ); + }, + 'expected_header' => '', + 'expected_footer' => ' + + + ', + 'expected_in_footer' => array( + 'script-a', + 'script-b', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 1, + 'jquery' => 0, + ), + ), + + 'footer-blocking-dependent-of-async-head-script' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'async' ) ); + wp_enqueue_script( 'script-b', 'https://example.com/script-b.js', array( 'script-a' ), null, array( 'in_footer' => true ) ); + }, + 'expected_header' => '', + 'expected_footer' => ' + + + ', + 'expected_in_footer' => array( + 'script-a', + 'script-b', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 1, + 'jquery' => 0, + ), + ), + + 'head-blocking-dependent-of-delayed-head-script' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( 'script-b', 'https://example.com/script-b.js', array( 'script-a' ), null, array( 'in_footer' => false ) ); + }, + 'expected_header' => ' + + + ', + 'expected_footer' => '', + 'expected_in_footer' => array(), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 0, + 'jquery' => 0, + ), + ), + + 'delayed-footer-dependent-of-delayed-head-script' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( + 'script-b', + 'https://example.com/script-b.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + }, + 'expected_header' => ' + + ', + 'expected_footer' => ' + + ', + 'expected_in_footer' => array( + 'script-b', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 1, + 'jquery' => 0, + ), + ), + + 'delayed-dependent-in-header-and-delayed-dependents-in-footer' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( + 'script-b', + 'https://example.com/script-b.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => false, + ) + ); + wp_enqueue_script( + 'script-c', + 'https://example.com/script-c.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + wp_enqueue_script( + 'script-d', + 'https://example.com/script-d.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + }, + 'expected_header' => ' + + + ', + 'expected_footer' => ' + + + ', + 'expected_in_footer' => array( + 'script-c', + 'script-d', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 0, + 'script-c' => 1, + 'script-d' => 1, + 'jquery' => 0, + ), + ), + + 'all-dependents-in-footer-with-one-blocking' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( + 'script-b', + 'https://example.com/script-b.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + wp_enqueue_script( 'script-c', 'https://example.com/script-c.js', array( 'script-a' ), null, true ); + wp_enqueue_script( + 'script-d', + 'https://example.com/script-d.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + }, + 'expected_header' => '', + 'expected_footer' => ' + + + + + ', + 'expected_in_footer' => array( + 'script-a', + 'script-b', + 'script-c', + 'script-d', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 1, + 'script-c' => 1, + 'script-d' => 1, + 'jquery' => 0, + + ), + ), + + 'blocking-dependents-in-head-and-footer' => array( + 'set_up' => static function () { + wp_enqueue_script( 'script-a', 'https://example.com/script-a.js', array(), null, array( 'strategy' => 'defer' ) ); + wp_enqueue_script( + 'script-b', + 'https://example.com/script-b.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => false, + ) + ); + wp_enqueue_script( 'script-c', 'https://example.com/script-c.js', array( 'script-a' ), null, true ); + wp_enqueue_script( + 'script-d', + 'https://example.com/script-d.js', + array( 'script-a' ), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + ) + ); + }, + 'expected_header' => ' + + + ', + 'expected_footer' => ' + + + ', + 'expected_in_footer' => array( + 'script-c', + 'script-d', + ), + 'expected_groups' => array( + 'script-a' => 0, + 'script-b' => 0, + 'script-c' => 1, + 'script-d' => 1, + 'jquery' => 0, + ), + ), + + ); + } }