diff --git a/phpcompat.xml.dist b/phpcompat.xml.dist index 66ea48ab78b8e..a4771be79618b 100644 --- a/phpcompat.xml.dist +++ b/phpcompat.xml.dist @@ -114,4 +114,12 @@ /sodium_compat/src/PHP52/SplFixedArray\.php$ + + + /src/wp-includes/script-loader\.php$ + + diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 87a8339ec89e2..370b341ddd21b 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2264,10 +2264,14 @@ function wp_print_head_scripts() { /** * Private, for use in *_footer_scripts hooks * - * In classic themes, when block styles are loaded on demand via {@see wp_load_classic_theme_block_styles_on_demand()}, - * this function is replaced by a closure in {@see wp_hoist_late_printed_styles()} which will capture the output of - * {@see print_late_styles()} before printing footer scripts as usual. The captured late-printed styles are then hoisted - * to the HEAD by means of the template enhancement output buffer. + * In classic themes, when block styles are loaded on demand via wp_load_classic_theme_block_styles_on_demand(), + * this function is replaced by a closure in wp_hoist_late_printed_styles() which will capture the printing of + * two sets of "late" styles to be hoisted to the HEAD by means of the template enhancement output buffer: + * + * 1. Styles related to blocks are inserted right after the wp-block-library stylesheet. + * 2. All other styles are appended to the end of the HEAD. + * + * The closure calls print_footer_scripts() to print scripts in the footer as usual. * * @since 3.3.0 */ @@ -3601,20 +3605,23 @@ function wp_load_classic_theme_block_styles_on_demand() { // The following two filters are added by default for block themes in _add_default_theme_supports(). /* - * Load separate block styles so that the large block-library stylesheet is not enqueued unconditionally, - * and so that block-specific styles will only be enqueued when they are used on the page. - * A priority of zero allows for this to be easily overridden by themes which wish to opt out. + * Load separate block styles so that the large block-library stylesheet is not enqueued unconditionally, and so + * that block-specific styles will only be enqueued when they are used on the page. A priority of zero allows for + * this to be easily overridden by themes which wish to opt out. If a site has explicitly opted out of loading + * separate block styles, then abort. */ add_filter( 'should_load_separate_core_block_assets', '__return_true', 0 ); + if ( ! wp_should_load_separate_core_block_assets() ) { + return; + } /* * Also ensure that block assets are loaded on demand (although the default value is from should_load_separate_core_block_assets). - * As above, a priority of zero allows for this to be easily overridden by themes which wish to opt out. + * As above, a priority of zero allows for this to be easily overridden by themes which wish to opt out. If a site + * has explicitly opted out of loading block styles on demand, then abort. */ add_filter( 'should_load_block_assets_on_demand', '__return_true', 0 ); - - // If a site has explicitly opted out of loading block styles on demand via filters with priorities higher than above, then abort. - if ( ! wp_should_load_separate_core_block_assets() || ! wp_should_load_block_assets_on_demand() ) { + if ( ! wp_should_load_block_assets_on_demand() ) { return; } @@ -3637,37 +3644,73 @@ function wp_hoist_late_printed_styles() { } /* - * While normally late styles are printed, there is a filter to disable prevent this, so this makes sure they are - * printed. Note that this filter was intended to control whether to print the styles queued too late for the HTML - * head. This filter was introduced in . However, with the template - * enhancement output buffer, essentially no style can be enqueued too late, because an output buffer filter can - * always hoist it to the HEAD. + * Add a placeholder comment into the inline styles for wp-block-library, after which where the late block styles + * can be hoisted from the footer to be printed in the header by means of a filter below on the template enhancement + * output buffer. The `wp_print_styles` action is used to ensure that if the inline style gets replaced at + * `enqueue_block_assets` or `wp_enqueue_scripts` that the placeholder will be sure to be present. */ - add_filter( 'print_late_styles', '__return_true', PHP_INT_MAX ); + $placeholder = sprintf( '/*%s*/', uniqid( 'wp_block_styles_on_demand_placeholder:' ) ); + add_action( + 'wp_print_styles', + static function () use ( $placeholder ) { + wp_add_inline_style( 'wp-block-library', $placeholder ); + } + ); /* - * Print a placeholder comment where the late styles can be hoisted from the footer to be printed in the header - * by means of a filter below on the template enhancement output buffer. + * Create a substitute for `print_late_styles()` which is aware of block styles. This substitute does not print + * the styles, but it captures what would be printed for block styles and non-block styles so that they can be + * later hoisted to the HEAD in the template enhancement output buffer. This will run at `wp_print_footer_scripts` + * before `print_footer_scripts()` is called. */ - $placeholder = sprintf( '/*%s*/', uniqid( 'wp_late_styles_placeholder:' ) ); + $printed_block_styles = ''; + $printed_late_styles = ''; + $capture_late_styles = static function () use ( &$printed_block_styles, &$printed_late_styles ) { + // Gather the styles related to on-demand block enqueues. + $all_block_style_handles = array(); + foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $block_type ) { + foreach ( $block_type->style_handles as $style_handle ) { + $all_block_style_handles[] = $style_handle; + } + } + $all_block_style_handles = array_merge( + $all_block_style_handles, + array( + 'global-styles', + 'block-style-variation-styles', + 'core-block-supports', + 'core-block-supports-duotone', + ) + ); - wp_add_inline_style( 'wp-block-library', $placeholder ); + /* + * First print all styles related to blocks which should inserted right after the wp-block-library stylesheet + * to preserve the CSS cascade. The logic in this `if` statement is derived from `wp_print_styles()`. + */ + $enqueued_block_styles = array_values( array_intersect( $all_block_style_handles, wp_styles()->queue ) ); + if ( count( $enqueued_block_styles ) > 0 ) { + ob_start(); + wp_styles()->do_items( $enqueued_block_styles ); + $printed_block_styles = ob_get_clean(); + } - // Wrap print_late_styles() with a closure that captures the late-printed styles. - $printed_late_styles = ''; - $capture_late_styles = static function () use ( &$printed_late_styles ) { + /* + * Print all remaining styles not related to blocks. This contains a subset of the logic from + * `print_late_styles()`, without admin-specific logic and the `print_late_styles` filter to control whether + * late styles are printed (since they are being hoisted anyway). + */ ob_start(); - print_late_styles(); + wp_styles()->do_footer_items(); $printed_late_styles = ob_get_clean(); }; /* - * If _wp_footer_scripts() was unhooked from the wp_print_footer_scripts action, or if wp_print_footer_scripts() - * was unhooked from running at the wp_footer action, then only add a callback to wp_footer which will capture the + * If `_wp_footer_scripts()` was unhooked from the `wp_print_footer_scripts` action, or if `wp_print_footer_scripts()` + * was unhooked from running at the `wp_footer` action, then only add a callback to `wp_footer` which will capture the * late-printed styles. * - * Otherwise, in the normal case where _wp_footer_scripts() will run at the wp_print_footer_scripts action, then - * swap out _wp_footer_scripts() with an alternative which captures the printed styles (for hoisting to HEAD) before + * Otherwise, in the normal case where `_wp_footer_scripts()` will run at the `wp_print_footer_scripts` action, then + * swap out `_wp_footer_scripts()` with an alternative which captures the printed styles (for hoisting to HEAD) before * proceeding with printing the footer scripts. */ $wp_print_footer_scripts_priority = has_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); @@ -3689,65 +3732,99 @@ static function () use ( $capture_late_styles ) { // Replace placeholder with the captured late styles. add_filter( 'wp_template_enhancement_output_buffer', - function ( $buffer ) use ( $placeholder, &$printed_late_styles ) { + static function ( $buffer ) use ( $placeholder, &$printed_block_styles, &$printed_late_styles ) { // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans. $processor = new class( $buffer ) extends WP_HTML_Tag_Processor { - public function get_span(): WP_HTML_Span { - $instance = $this; // phpcs:ignore PHPCompatibility.FunctionDeclarations.NewClosure.ThisFoundOutsideClass -- It is inside an anonymous class. - $instance->set_bookmark( 'here' ); - return $instance->bookmarks['here']; + /** + * Gets the span for the current token. + * + * @return WP_HTML_Span Current token span. + */ + private function get_span(): WP_HTML_Span { + // Note: This call will never fail according to the usage of this class, given it is always called after ::next_tag() is true. + $this->set_bookmark( 'here' ); + return $this->bookmarks['here']; + } + + /** + * Inserts text before the current token. + * + * @param string $text Text to insert. + */ + public function insert_before( string $text ) { + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->get_span()->start, 0, $text ); + } + + /** + * Inserts text after the current token. + * + * @param string $text Text to insert. + */ + public function insert_after( string $text ) { + $span = $this->get_span(); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start + $span->length, 0, $text ); + } + + /** + * Removes the current token. + */ + public function remove() { + $span = $this->get_span(); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' ); } }; - // Loop over STYLE tags. + /* + * Insert block styles right after wp-block-library (if it is present), and then insert any remaining styles + * at (or else print everything there). The placeholder CSS comment will always be added to the + * wp-block-library inline style since it gets printed at `wp_head` before the blocks are rendered. + * This means that there may not actually be any block styles to hoist from the footer to insert after this + * inline style. The placeholder CSS comment needs to be added so that the inline style gets printed, but + * if the resulting inline style is empty after the placeholder is removed, then the inline style is + * removed. + */ while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - - // We've encountered the inline style for the 'wp-block-library' stylesheet which probably has the placeholder comment. if ( - ! $processor->is_tag_closer() && 'STYLE' === $processor->get_tag() && 'wp-block-library-inline-css' === $processor->get_attribute( 'id' ) ) { - // If the inline style lacks the placeholder comment, then we have to continue until we get to to append the styles there. $css_text = $processor->get_modifiable_text(); - if ( ! str_contains( $css_text, $placeholder ) ) { - continue; - } - // Remove the placeholder now that we've located the inline style. - $processor->set_modifiable_text( str_replace( $placeholder, '', $css_text ) ); - $buffer = $processor->get_updated_html(); + /* + * A placeholder CSS comment is added to the inline style in order to force an inline STYLE tag to + * be printed. Now that we've located the inline style, the placeholder comment can be removed. If + * there is no CSS left in the STYLE tag after removing the placeholder (aside from the sourceURL + * comment, then remove the STYLE entirely.) + */ + $css_text = str_replace( $placeholder, '', $css_text ); + if ( preg_match( ':^/\*# sourceURL=\S+? \*/$:', trim( $css_text ) ) ) { + $processor->remove(); + } else { + $processor->set_modifiable_text( $css_text ); + } // Insert the $printed_late_styles immediately after the closing inline STYLE tag. This preserves the CSS cascade. - $span = $processor->get_span(); - $buffer = implode( - '', - array( - substr( $buffer, 0, $span->start + $span->length ), - $printed_late_styles, - substr( $buffer, $span->start + $span->length ), - ) - ); - break; - } + if ( '' !== $printed_block_styles ) { + $processor->insert_after( $printed_block_styles ); - // As a fallback, append the hoisted late styles to the end of the HEAD. - if ( $processor->is_tag_closer() && 'HEAD' === $processor->get_tag() ) { - $span = $processor->get_span(); - $buffer = implode( - '', - array( - substr( $buffer, 0, $span->start ), - $printed_late_styles, - substr( $buffer, $span->start ), - ) - ); + // Prevent printing them again at . + $printed_block_styles = ''; + } + + // If there aren't any late styles, there's no need to continue to finding . + if ( '' === $printed_late_styles ) { + break; + } + } elseif ( 'HEAD' === $processor->get_tag() && $processor->is_tag_closer() ) { + $processor->insert_before( $printed_block_styles . $printed_late_styles ); break; } } - return $buffer; + return $processor->get_updated_html(); } ); } diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php index 6118a2a43b577..a304fff95f865 100644 --- a/tests/phpunit/tests/template.php +++ b/tests/phpunit/tests/template.php @@ -127,10 +127,7 @@ public function set_up() { $this->original_wp_styles = $wp_styles; $wp_scripts = null; $wp_styles = null; - wp_scripts(); - wp_styles(); - $this->original_theme_features = $GLOBALS['_wp_theme_features']; foreach ( self::RESTORED_CONFIG_OPTIONS as $option ) { $this->original_ini_config[ $option ] = ini_get( $option ); } @@ -141,7 +138,6 @@ public function tear_down() { $wp_scripts = $this->original_wp_scripts; $wp_styles = $this->original_wp_styles; - $GLOBALS['_wp_theme_features'] = $this->original_theme_features; foreach ( $this->original_ini_config as $option => $value ) { ini_set( $option, $value ); } @@ -149,6 +145,7 @@ public function tear_down() { unregister_post_type( 'cpt' ); unregister_taxonomy( 'taxo' ); $this->set_permalink_structure( '' ); + parent::tear_down(); } @@ -630,7 +627,7 @@ public function test_wp_start_template_enhancement_output_buffer_begins_with_fil add_filter( 'wp_template_enhancement_output_buffer', static function () { - return 'Hey!'; + return 'Hey!'; } ); $level = ob_get_level(); @@ -1422,7 +1419,7 @@ public function data_wp_load_classic_theme_block_styles_on_demand(): array { add_filter( 'should_load_separate_core_block_assets', '__return_false' ); }, 'expected_load_separate' => false, - 'expected_on_demand' => true, + 'expected_on_demand' => false, 'expected_buffer_started' => false, ), 'classic_theme_with_should_load_block_assets_on_demand_out_out' => array( @@ -1475,33 +1472,197 @@ public function test_wp_load_classic_theme_block_styles_on_demand( string $theme /** * Data provider. * - * @return array + * @return array */ public function data_wp_hoist_late_printed_styles(): array { + $common_expected_head_styles = array( + 'wp-img-auto-sizes-contain-inline-css', + 'early-css', + 'early-inline-css', + 'wp-emoji-styles-inline-css', + 'wp-block-library-css', + 'wp-block-separator-css', + 'global-styles-inline-css', + 'core-block-supports-inline-css', + 'classic-theme-styles-css', + 'normal-css', + 'normal-inline-css', + 'wp-custom-css', + 'late-css', + 'late-inline-css', + ); + return array( - 'no_actions_removed' => array( - 'set_up' => null, + 'standard_classic_theme_config_with_min_styles_inlined' => array( + 'set_up' => null, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => $common_expected_head_styles, + 'BODY' => array(), + ), + ), + 'standard_classic_theme_config_with_max_styles_inlined' => array( + 'set_up' => null, + 'inline_size_limit' => PHP_INT_MAX, + 'expected_styles' => array( + 'HEAD' => array( + 'wp-img-auto-sizes-contain-inline-css', + 'early-css', + 'early-inline-css', + 'wp-emoji-styles-inline-css', + 'wp-block-library-inline-css', + 'wp-block-separator-inline-css', + 'global-styles-inline-css', + 'core-block-supports-inline-css', + 'classic-theme-styles-inline-css', + 'normal-css', + 'normal-inline-css', + 'wp-custom-css', + 'late-css', + 'late-inline-css', + ), + 'BODY' => array(), + ), + ), + 'standard_classic_theme_config_extra_block_library_inline_style' => array( + 'set_up' => static function () { + add_action( + 'enqueue_block_assets', + static function () { + wp_add_inline_style( 'wp-block-library', '/* Extra CSS which prevents empty inline style containing placeholder from being removed. */' ); + } + ); + }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => ( function ( $expected_styles ) { + // Insert 'wp-block-library-inline-css' right after 'wp-block-library-css'. + $i = array_search( 'wp-block-library-css', $expected_styles, true ); + $this->assertIsInt( $i, 'Expected wp-block-library-css to be among the styles.' ); + array_splice( $expected_styles, $i + 1, 0, 'wp-block-library-inline-css' ); + return $expected_styles; + } )( $common_expected_head_styles ), + 'BODY' => array(), + ), ), - '_wp_footer_scripts_removed' => array( - 'set_up' => static function () { + 'classic_theme_opt_out_separate_block_styles' => array( + 'set_up' => static function () { + add_filter( 'should_load_separate_core_block_assets', '__return_false' ); + }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => array( + 'wp-img-auto-sizes-contain-inline-css', + 'early-css', + 'early-inline-css', + 'wp-emoji-styles-inline-css', + 'wp-block-library-css', + 'classic-theme-styles-css', + 'global-styles-inline-css', + 'normal-css', + 'normal-inline-css', + 'wp-custom-css', + ), + 'BODY' => array( + 'late-css', + 'late-inline-css', + 'core-block-supports-inline-css', + ), + ), + ), + '_wp_footer_scripts_removed' => array( + 'set_up' => static function () { remove_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => $common_expected_head_styles, + 'BODY' => array(), + ), ), - 'wp_print_footer_scripts_removed' => array( - 'set_up' => static function () { + 'wp_print_footer_scripts_removed' => array( + 'set_up' => static function () { remove_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => $common_expected_head_styles, + 'BODY' => array(), + ), ), - 'both_actions_removed' => array( - 'set_up' => static function () { + 'both_actions_removed' => array( + 'set_up' => static function () { remove_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); remove_action( 'wp_footer', 'wp_print_footer_scripts' ); }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => $common_expected_head_styles, + 'BODY' => array(), + ), ), - 'block_library_removed' => array( - 'set_up' => static function () { - wp_deregister_style( 'wp-block-library' ); + 'disable_block_library' => array( + 'set_up' => static function () { + add_action( + 'enqueue_block_assets', + function (): void { + wp_deregister_style( 'wp-block-library' ); + wp_register_style( 'wp-block-library', '' ); + } + ); + add_filter( 'should_load_separate_core_block_assets', '__return_false' ); }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => array( + 'wp-img-auto-sizes-contain-inline-css', + 'early-css', + 'early-inline-css', + 'wp-emoji-styles-inline-css', + 'classic-theme-styles-css', + 'global-styles-inline-css', + 'normal-css', + 'normal-inline-css', + 'wp-custom-css', + ), + 'BODY' => array( + 'late-css', + 'late-inline-css', + 'core-block-supports-inline-css', + ), + ), + ), + 'override_block_library_inline_style_late' => array( + 'set_up' => static function () { + add_action( + 'enqueue_block_assets', + function (): void { + // This tests what happens when the placeholder comment gets replaced unexpectedly. + wp_styles()->registered['wp-block-library']->extra['after'] = array( '/* OVERRIDDEN! */' ); + } + ); + }, + 'inline_size_limit' => 0, + 'expected_styles' => array( + 'HEAD' => array( + 'wp-img-auto-sizes-contain-inline-css', + 'early-css', + 'early-inline-css', + 'wp-emoji-styles-inline-css', + 'wp-block-library-css', + 'wp-block-library-inline-css', // This contains the "OVERRIDDEN" text. + 'wp-block-separator-css', + 'global-styles-inline-css', + 'core-block-supports-inline-css', + 'classic-theme-styles-css', + 'normal-css', + 'normal-inline-css', + 'wp-custom-css', + 'late-css', + 'late-inline-css', + ), + 'BODY' => array(), + ), ), ); } @@ -1510,26 +1671,68 @@ public function data_wp_hoist_late_printed_styles(): array { * Tests that wp_hoist_late_printed_styles() adds a placeholder for delayed CSS, then removes it and adds all CSS to the head including late enqueued styles. * * @ticket 64099 + * @covers ::wp_load_classic_theme_block_styles_on_demand * @covers ::wp_hoist_late_printed_styles * * @dataProvider data_wp_hoist_late_printed_styles */ - public function test_wp_hoist_late_printed_styles( ?Closure $set_up ): void { + public function test_wp_hoist_late_printed_styles( ?Closure $set_up, int $inline_size_limit, array $expected_styles ): void { + switch_theme( 'default' ); + global $wp_styles; + $wp_styles = null; + + // Disable the styles_inline_size_limit in order to prevent changes from invalidating the snapshots. + add_filter( + 'styles_inline_size_limit', + static function () use ( $inline_size_limit ): int { + return $inline_size_limit; + } + ); + + add_filter( + 'wp_get_custom_css', + static function () { + return '/* CUSTOM CSS from Customizer */'; + } + ); + if ( $set_up ) { $set_up(); } - switch_theme( 'default' ); + wp_load_classic_theme_block_styles_on_demand(); + + // Ensure that separate core block assets get registered. + register_core_block_style_handles(); + $this->assertTrue( WP_Block_Type_Registry::get_instance()->is_registered( 'core/separator' ), 'Expected the core/separator block to be registered.' ); + + // Ensure stylesheet files exist on the filesystem since a build may not have been done. + $this->ensure_style_asset_file_created( + 'wp-block-library', + wp_should_load_separate_core_block_assets() ? 'css/dist/block-library/common.css' : 'css/dist/block-library/style.css' + ); + if ( wp_should_load_separate_core_block_assets() ) { + $this->ensure_style_asset_file_created( 'wp-block-separator', 'blocks/separator/style.css' ); + } + $this->assertFalse( wp_is_block_theme(), 'Test is not relevant to block themes (only classic themes).' ); - // Enqueue a style - wp_enqueue_style( 'early', 'http://example.com/style.css' ); + // Enqueue a style early, before wp_enqueue_scripts. + wp_enqueue_style( 'early', 'https://example.com/style.css' ); wp_add_inline_style( 'early', '/* EARLY */' ); - wp_hoist_late_printed_styles(); + // Enqueue a style at the normal spot. + add_action( + 'wp_enqueue_scripts', + static function () { + wp_enqueue_style( 'normal', 'https://example.com/normal.css' ); + wp_add_inline_style( 'normal', '/* NORMAL */' ); + } + ); - // Ensure late styles are printed. - add_filter( 'print_late_styles', '__return_false', 1000 ); - $this->assertTrue( apply_filters( 'print_late_styles', true ), 'Expected late style printing to be forced.' ); + // Call wp_hoist_late_printed_styles() if wp_load_classic_theme_block_styles_on_demand() queued it up. + if ( has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) { + wp_hoist_late_printed_styles(); + } // Simulate wp_head. $head_output = get_echo( 'wp_head' ); @@ -1537,21 +1740,32 @@ public function test_wp_hoist_late_printed_styles( ?Closure $set_up ): void { $this->assertStringContainsString( 'early', $head_output, 'Expected the early-enqueued stylesheet to be present.' ); // Enqueue a late style (after wp_head). - wp_enqueue_style( 'late', 'http://example.com/late-style.css', array(), null ); - wp_add_inline_style( 'late', '/* EARLY */' ); + wp_enqueue_style( 'late', 'https://example.com/late-style.css', array(), null ); + wp_add_inline_style( 'late', '/* LATE */' ); + + // Simulate the_content(). + $content = apply_filters( + 'the_content', + '
' + ); // Simulate footer scripts. $footer_output = get_echo( 'wp_footer' ); // Create a simulated output buffer. - $buffer = '' . $head_output . '
Content
' . $footer_output . ''; + $buffer = '' . $head_output . '
' . $content . '
' . $footer_output . ''; + + $placeholder_regexp = '#/\*wp_block_styles_on_demand_placeholder:[a-f0-9]+\*/#'; + if ( has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) { + $this->assertMatchesRegularExpression( $placeholder_regexp, $buffer, 'Expected the placeholder to be present in the buffer.' ); + } // Apply the output buffer filter. $filtered_buffer = apply_filters( 'wp_template_enhancement_output_buffer', $buffer ); - $this->assertStringContainsString( '', $buffer, 'Expected the closing HEAD tag to be in the response.' ); + $this->assertStringContainsString( '', $filtered_buffer, 'Expected the closing HEAD tag to be in the response.' ); - $this->assertDoesNotMatchRegularExpression( '#/\*wp_late_styles_placeholder:[a-f0-9-]+\*/#', $filtered_buffer, 'Expected the placeholder to be removed.' ); + $this->assertDoesNotMatchRegularExpression( $placeholder_regexp, $filtered_buffer, 'Expected the placeholder to be removed.' ); $found_styles = array( 'HEAD' => array(), 'BODY' => array(), @@ -1569,21 +1783,58 @@ public function test_wp_hoist_late_printed_styles( ?Closure $set_up ): void { } } - $expected = array( - 'early-css', - 'early-inline-css', - 'late-css', - 'late-inline-css', + /* + * Since new styles could appear at any time and since certain styles leak in from the global scope not being + * properly reset somewhere else in the test suite, we only check that the expected styles are at least present + * and in the same order. When new styles are introduced in core, they may be added to this array as opposed to + * updating the arrays in the data provider, if appropriate. + */ + $ignored_styles = array( + 'core-block-supports-duotone-inline-css', + 'wp-block-library-theme-css', + 'wp-block-template-skip-link-inline-css', ); - foreach ( $expected as $style_id ) { - $this->assertContains( $style_id, $found_styles['HEAD'], 'Expected stylesheet with ID to be in the HEAD.' ); + + $found_subset_styles = array(); + foreach ( array( 'HEAD', 'BODY' ) as $group ) { + $found_subset_styles[ $group ] = array_values( array_diff( $found_styles[ $group ], $ignored_styles ) ); } + $this->assertSame( - $expected, - array_values( array_intersect( $found_styles['HEAD'], $expected ) ), - 'Expected styles to be printed in the same order.' + $expected_styles, + $found_subset_styles, + 'Expected the same styles. Snapshot: ' . self::get_array_snapshot_export( $found_subset_styles ) ); - $this->assertCount( 0, $found_styles['BODY'], 'Expected no styles to be present in the footer.' ); + } + + /** + * Ensures a CSS file is on the filesystem. + * + * This is needed because unit tests may be run without a build step having been done. Something similar can be seen + * elsewhere in tests for the `wp-emoji-loader.js` script: + * + * self::touch( ABSPATH . WPINC . '/js/wp-emoji-loader.js' ); + * + * @param string $handle Style handle. + * @param string $relative_path Relative path to the CSS file in wp-includes. + * + * @throws Exception If the supplied style handle is not registered as expected. + */ + private function ensure_style_asset_file_created( string $handle, string $relative_path ) { + $dependency = wp_styles()->query( $handle ); + if ( ! $dependency ) { + throw new Exception( "The stylesheet for $handle is not registered." ); + } + $dependency->src = includes_url( $relative_path ); + $path = ABSPATH . WPINC . '/' . $relative_path; + if ( ! file_exists( $path ) ) { + $dir = dirname( $path ); + if ( ! file_exists( $dir ) ) { + mkdir( $dir, 0777, true ); + } + file_put_contents( $path, "/* CSS for $handle */" ); + } + wp_style_add_data( $handle, 'path', $path ); } public function assertTemplateHierarchy( $url, array $expected, $message = '' ) { @@ -1593,6 +1844,49 @@ public function assertTemplateHierarchy( $url, array $expected, $message = '' ) $this->assertSame( $expected, $hierarchy, $message ); } + /** + * Exports PHP array as string formatted as a snapshot for pasting into a data provider. + * + * Unfortunately, `var_export()` always includes array indices even for lists. For example: + * + * var_export( array( 'a', 'b', 'c' ) ); + * + * Results in: + * + * array ( + * 0 => 'a', + * 1 => 'b', + * 2 => 'c', + * ) + * + * This makes it unhelpful when outputting a snapshot to update a unit test. So this function strips out the indices + * to facilitate copy/pasting the snapshot from an assertion error message into the data provider. For example: + * + * array( + * 'a', + * 'b', + * 'c', + * ) + * + * + * @param array $snapshot Snapshot. + * @return string Snapshot export. + */ + private static function get_array_snapshot_export( array $snapshot ): string { + $export = var_export( $snapshot, true ); + $export = preg_replace( '/\barray \($/m', 'array(', $export ); + $export = preg_replace( '/^(\s+)\d+\s+=>\s+/m', '$1', $export ); + $export = preg_replace( '/=> *\n +/', '=> ', $export ); + $export = preg_replace( '/array\(\n\s+\)/', 'array()', $export ); + return preg_replace_callback( + '/(^ +)/m', + static function ( $matches ) { + return str_repeat( "\t", strlen( $matches[0] ) / 2 ); + }, + $export + ); + } + protected static function get_query_template_conditions() { return array( 'embed' => 'is_embed',