From 81c9fbcc54e6fd4089419b7798de8ddf5084e7f6 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Wed, 25 Feb 2026 12:42:25 -0600 Subject: [PATCH 1/4] Background: render media library images as for srcset/lazy-loading --- src/wp-includes/block-supports/background.php | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index cc4d451e1d8bd..e7d25b6d7ab7b 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -43,6 +43,7 @@ function wp_register_background_support( $block_type ) { * @since 6.5.0 Added support for `backgroundPosition` and `backgroundRepeat` output. * @since 6.6.0 Removed requirement for `backgroundImage.source`. A file/url is the default. * @since 6.7.0 Added support for `backgroundAttachment` output. + * @since 6.8.0 Added support for rendering background images as elements. * * @access private * @@ -79,6 +80,81 @@ function wp_render_background_support( $block_content, $block ) { } } + /* + * Use an element for media library images with cover/contain sizing and no tiling, + * so the browser can benefit from srcset, sizes, loading="lazy", and decoding="async". + * 'no-repeat' is treated the same as unset — both are compatible with object-fit. + * Only explicit tiling values (repeat, repeat-x, repeat-y) require CSS background-image. + */ + $attachment_id = is_array( $background_styles['backgroundImage'] ) + ? (int) ( $background_styles['backgroundImage']['id'] ?? 0 ) + : 0; + + $use_img_element = ( + $attachment_id > 0 && + in_array( $background_styles['backgroundSize'], array( 'cover', 'contain' ), true ) && + ( empty( $background_styles['backgroundRepeat'] ) || 'no-repeat' === $background_styles['backgroundRepeat'] ) && + empty( $background_styles['backgroundAttachment'] ) + ); + + if ( $use_img_element ) { + $object_fit = $background_styles['backgroundSize']; + $object_position = $background_styles['backgroundPosition'] ?? null; + + $img_style = 'position:absolute;top:0;left:0;right:0;bottom:0;margin:0;padding:0;width:100%;height:100%;max-width:none;max-height:none;pointer-events:none;object-fit:' . esc_attr( $object_fit ) . ';'; + if ( $object_position ) { + $img_style .= 'object-position:' . esc_attr( $object_position ) . ';'; + } + + $img_attrs = array( + 'class' => 'wp-block__background-image', + 'style' => $img_style, + 'alt' => '', + 'aria-hidden' => 'true', + 'data-object-fit' => $object_fit, + 'loading' => 'lazy', + 'decoding' => 'async', + ); + if ( $object_position ) { + $img_attrs['data-object-position'] = $object_position; + } + + $img_html = wp_get_attachment_image( $attachment_id, 'full', false, $img_attrs ); + + if ( $img_html ) { + $tags = new WP_HTML_Tag_Processor( $block_content ); + if ( $tags->next_tag() ) { + $tag_name = strtolower( $tags->get_tag() ); + $existing_style = $tags->get_attribute( 'style' ); + + if ( is_string( $existing_style ) && '' !== $existing_style ) { + $separator = str_ends_with( $existing_style, ';' ) ? '' : ';'; + // Only add position:relative when there is no existing position rule. + if ( ! preg_match( '/(?:^|;)\s*position\s*:/', $existing_style ) ) { + $wrapper_style = $existing_style . $separator . 'position:relative;'; + } else { + $wrapper_style = rtrim( $existing_style, ';' ) . ';'; + } + } else { + $wrapper_style = 'position:relative;'; + } + + $tags->set_attribute( 'style', $wrapper_style ); + $tags->add_class( 'has-background' ); + } + $modified_content = $tags->get_updated_html(); + + // Insert the img as the first child of the wrapper element. + $open_tag_pattern = sprintf( '/^(\s*<%s[^>]*>)/i', preg_quote( $tag_name, '/' ) ); + if ( 1 === preg_match( $open_tag_pattern, $modified_content, $matches, PREG_OFFSET_CAPTURE ) ) { + $insert_at = $matches[0][1] + strlen( $matches[0][0] ); + $modified_content = substr( $modified_content, 0, $insert_at ) . $img_html . substr( $modified_content, $insert_at ); + } + + return $modified_content; + } + } + $styles = wp_style_engine_get_styles( array( 'background' => $background_styles ) ); if ( ! empty( $styles['css'] ) ) { From 087a6b8acdd6e053e5bab0ef419029900a56d9c5 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Wed, 25 Feb 2026 12:43:19 -0600 Subject: [PATCH 2/4] Tests: add background img element and CSS fallback coverage --- .../wpRenderBackgroundSupport.php | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 5a4faf46085c0..a5e849bf7e85b 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -213,6 +213,156 @@ public function data_background_block_support() { 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), + 'background image with attachment id and repeat falls back to CSS' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-rules-are-output', + 'background_settings' => array( + 'backgroundImage' => true, + ), + 'background_style' => array( + 'backgroundImage' => array( + 'id' => 99999, + 'url' => 'https://example.com/image.jpg', + ), + 'backgroundRepeat' => 'repeat', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background image with attachment id and fixed attachment falls back to CSS' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-rules-are-output', + 'background_settings' => array( + 'backgroundImage' => true, + ), + 'background_style' => array( + 'backgroundImage' => array( + 'id' => 99999, + 'url' => 'https://example.com/image.jpg', + ), + 'backgroundSize' => 'cover', + 'backgroundAttachment' => 'fixed', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + ); + } + + /** + * Tests that a background image with backgroundRepeat:'no-repeat' still injects + * an img element (no-repeat is compatible with object-fit; only tiled/repeat + * values require CSS background-image). + * + * @covers ::wp_render_background_support + */ + public function test_background_img_element_is_injected_with_no_repeat() { + switch_theme( 'block-theme-child-with-fluid-typography' ); + $this->test_block_name = 'test/background-img-element-no-repeat'; + + $attachment_id = self::factory()->attachment->create_upload_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( 'post_mime_type' => 'image/jpeg' ) + ); + + register_block_type( + $this->test_block_name, + array( + 'api_version' => 2, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'background' => array( + 'backgroundImage' => true, + ), + ), + ) + ); + + $block = array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'background' => array( + 'backgroundImage' => array( + 'id' => $attachment_id, + 'url' => wp_get_attachment_url( $attachment_id ), + ), + 'backgroundSize' => 'contain', + 'backgroundRepeat' => 'no-repeat', + ), + ), + ), + ); + + $actual = wp_render_background_support( '
Content
', $block ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'background-image:', $actual, 'Output should not contain background-image CSS.' ); + $this->assertStringContainsString( 'has-background', $actual, 'Wrapper should have has-background class.' ); + $this->assertStringContainsString( 'position:relative', $actual, 'Wrapper should have position:relative style.' ); + + wp_delete_attachment( $attachment_id, true ); + } + + /** + * Tests that a background image with an attachment ID injects an img element. + * + * @covers ::wp_render_background_support + */ + public function test_background_img_element_is_injected() { + switch_theme( 'block-theme-child-with-fluid-typography' ); + $this->test_block_name = 'test/background-img-element'; + + $attachment_id = self::factory()->attachment->create_upload_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( 'post_mime_type' => 'image/jpeg' ) + ); + + register_block_type( + $this->test_block_name, + array( + 'api_version' => 2, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'background' => array( + 'backgroundImage' => true, + ), + ), + ) ); + + $block = array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'background' => array( + 'backgroundImage' => array( + 'id' => $attachment_id, + 'url' => wp_get_attachment_url( $attachment_id ), + ), + 'backgroundSize' => 'cover', + ), + ), + ), + ); + + $actual = wp_render_background_support( '
Content
', $block ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'background-image:', $actual, 'Output should not contain background-image CSS.' ); + $this->assertStringContainsString( 'has-background', $actual, 'Wrapper should have has-background class.' ); + $this->assertStringContainsString( 'position:relative', $actual, 'Wrapper should have position:relative style.' ); + + wp_delete_attachment( $attachment_id, true ); } } From 290a9ec6d14f70be0beb40eeff42cf25cdc9e709 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Wed, 25 Feb 2026 13:19:54 -0600 Subject: [PATCH 3/4] Tests: add @ticket 64725 to background img element test methods --- .../tests/block-supports/wpRenderBackgroundSupport.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index a5e849bf7e85b..98a08bccb59d4 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -254,6 +254,8 @@ public function data_background_block_support() { * an img element (no-repeat is compatible with object-fit; only tiled/repeat * values require CSS background-image). * + * @ticket 64725 + * * @covers ::wp_render_background_support */ public function test_background_img_element_is_injected_with_no_repeat() { @@ -312,6 +314,8 @@ public function test_background_img_element_is_injected_with_no_repeat() { /** * Tests that a background image with an attachment ID injects an img element. * + * @ticket 64725 + * * @covers ::wp_render_background_support */ public function test_background_img_element_is_injected() { From 674d49bbb2a4d08b8a3a1d01db7810f28d4b4ba3 Mon Sep 17 00:00:00 2001 From: Jos Velasco Date: Wed, 25 Feb 2026 16:33:27 -0600 Subject: [PATCH 4/4] =?UTF-8?q?block-supports/background:=20fix=20img=20in?= =?UTF-8?q?sertion=20=E2=80=94=20replace=20corrupted=20regex=20with=20strp?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preg_match regex pattern had a backspace control character (\x08) where \b should have been, causing the regex to never match and the to never be inserted into the wrapper. Replace the entire regex+preg_match block with a simple strpos() call to find the first '>' (the end of the opening tag). WP_HTML_Tag_Processor::get_updated_html() guarantees attribute values are properly escaped, so the first raw '>' is always the closing bracket of the opening wrapper tag. --- src/wp-includes/block-supports/background.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index e7d25b6d7ab7b..bece55d80fca9 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -145,10 +145,13 @@ function wp_render_background_support( $block_content, $block ) { $modified_content = $tags->get_updated_html(); // Insert the img as the first child of the wrapper element. - $open_tag_pattern = sprintf( '/^(\s*<%s[^>]*>)/i', preg_quote( $tag_name, '/' ) ); - if ( 1 === preg_match( $open_tag_pattern, $modified_content, $matches, PREG_OFFSET_CAPTURE ) ) { - $insert_at = $matches[0][1] + strlen( $matches[0][0] ); - $modified_content = substr( $modified_content, 0, $insert_at ) . $img_html . substr( $modified_content, $insert_at ); + // Find the end of the opening tag by locating the first '>'. The + // WP_HTML_Tag_Processor guarantees attribute values are properly + // escaped, so the first raw '>' is always the closing bracket of + // the opening wrapper tag. + $close_bracket = strpos( $modified_content, '>' ); + if ( false !== $close_bracket ) { + $modified_content = substr( $modified_content, 0, $close_bracket + 1 ) . $img_html . substr( $modified_content, $close_bracket + 1 ); } return $modified_content;