From d4887366253a3ba57f91371d24baf88cd5e335f6 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 5 Jul 2026 14:22:45 +0600 Subject: [PATCH 1/2] fix(optimization-detective): skip oversized Link preload header - Add od_get_maximum_link_response_header_length() with filter od_link_response_header_max_length (default 4KB) - Skip sending the Link response header when it would exceed the limit, falling back to the existing HTML link tag output - Add tests covering the new getter and the HTML fallback Some reverse proxies fail requests with an oversized response header, which can happen when responsive images have long or non-ASCII filenames across multiple viewport breakpoints. The HTML link tags already carry the same preload info, so skipping the header when it is too large keeps the request from failing without losing preload behavior. Fixes #2304 --- plugins/optimization-detective/docs/hooks.md | 19 +++++ .../optimization-detective/optimization.php | 46 +++++++++++- .../tests/test-optimization.php | 71 +++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/docs/hooks.md b/plugins/optimization-detective/docs/hooks.md index 208b245cea..2667a48b44 100644 --- a/plugins/optimization-detective/docs/hooks.md +++ b/plugins/optimization-detective/docs/hooks.md @@ -414,6 +414,25 @@ Filters the maximum allowed size in bytes for a URL Metric serialized to JSON. The filtered value must be greater than zero; otherwise it will be ignored, and a usage warning will result. +### Filter: `od_link_response_header_max_length` (default: 4 KB in bytes) + +Filters the maximum allowed length in bytes for the `Link` response header used to preload images and other subresources discovered by Optimization Detective. + +Some reverse proxies (e.g. Nginx) fail a request with an error like "upstream sent too big header" if a response header grows too large, which can happen when there are long or non-ASCII `srcset` URLs across multiple viewport breakpoints. If the constructed `Link` header would exceed this length, it is omitted from the response, and only the equivalent `LINK` tags injected into the `HEAD` are relied on for preloading. + +The filtered value must be greater than zero; otherwise it will be ignored, and a usage warning will result. + +Sites that know their proxy/server can handle larger headers can raise (or remove) this cap: + +```php +add_filter( + 'od_link_response_header_max_length', + function (): int { + return 16 * KB_IN_BYTES; + } +); +``` + ### Filter: `od_gzip_url_metric_store_request_payloads` (default: `true` if the `gzdecode()` function exists) Filters whether URL Metric JSON data should be compressed with gzip when being submitted to the `/url-metrics:store` REST API endpoint. diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 2317f26e16..6c44d6ee47 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -171,6 +171,46 @@ function od_is_response_html_content_type(): bool { return $is_html_content_type; } +/** + * Gets the maximum allowed length in bytes for the Link response header. + * + * If the constructed Link header would exceed this length, it is omitted from the response entirely and only + * the equivalent LINK tags are relied on, since some reverse proxies (e.g. Nginx) fail with an error such as + * "upstream sent too big header" when a response header is too large. + * + * @since n.e.x.t + * @access private + * + * @return positive-int Maximum allowed byte length. + */ +function od_get_maximum_link_response_header_length(): int { + /** + * Filters the maximum allowed length in bytes for the Link response header. + * + * @since n.e.x.t + * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_link_response_header_max_length + * + * @param int $max_length Maximum allowed byte length. + * @return int Filtered maximum allowed byte length. + */ + $length = (int) apply_filters( 'od_link_response_header_max_length', 4 * KB_IN_BYTES ); + if ( $length <= 0 ) { + _doing_it_wrong( + esc_html( "Filter: 'od_link_response_header_max_length'" ), + esc_html( + sprintf( + /* translators: %s: length */ + __( 'Invalid length "%s". Must be greater than zero.', 'optimization-detective' ), + $length + ) + ), + 'Optimization Detective n.e.x.t' + ); + $length = 4 * KB_IN_BYTES; + } + return $length; +} + /** * Optimizes template output buffer. * @@ -358,7 +398,11 @@ function od_optimize_template_output_buffer( string $buffer ): string { // Additional links may have been added at the od_finish_template_optimization action, so this must come after. if ( count( $link_collection ) > 0 ) { $response_header_links = $link_collection->get_response_header(); - if ( ! is_null( $response_header_links ) && ! headers_sent() ) { + if ( + ! is_null( $response_header_links ) + && ! headers_sent() + && strlen( $response_header_links ) <= od_get_maximum_link_response_header_length() + ) { header( $response_header_links, false ); } $processor->append_head_html( $link_collection->get_html() ); diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index b27f43efdc..c6ae4e13b1 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -548,4 +548,75 @@ function ( OD_Template_Optimization_Context $context ) use ( &$template_optimiza $this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_start_template_optimization' ) ); $this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_finish_template_optimization' ) ); } + + /** + * Adds a preload link for the given href at the od_finish_template_optimization action. + * + * @param non-empty-string $href Href. + */ + private function add_preload_link_at_finish( string $href ): void { + add_action( + 'od_finish_template_optimization', + static function ( OD_Template_Optimization_Context $context ) use ( $href ): void { + $context->link_collection->add_link( + array( + 'rel' => 'preload', + 'href' => $href, + 'as' => 'image', + ) + ); + } + ); + } + + /** + * Tests the default value for the maximum Link response header length. + * + * @covers ::od_get_maximum_link_response_header_length + */ + public function test_od_get_maximum_link_response_header_length_default(): void { + $this->assertSame( 4 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); + } + + /** + * Tests that the od_link_response_header_max_length filter can change the maximum Link response header length. + * + * @covers ::od_get_maximum_link_response_header_length + */ + public function test_od_get_maximum_link_response_header_length_filter(): void { + add_filter( + 'od_link_response_header_max_length', + static function (): int { + return 8 * KB_IN_BYTES; + } + ); + $this->assertSame( 8 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); + } + + /** + * Tests that an invalid od_link_response_header_max_length filter value falls back to the default. + * + * @covers ::od_get_maximum_link_response_header_length + */ + public function test_od_get_maximum_link_response_header_length_invalid_value(): void { + add_filter( 'od_link_response_header_max_length', '__return_zero' ); + $this->setExpectedIncorrectUsage( 'Filter: 'od_link_response_header_max_length'' ); + + $this->assertSame( 4 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); + } + + /** + * Tests that the HTML link tag fallback is output even when the corresponding Link response header would be + * oversized, so preloading is never lost even when the header is skipped. + * + * @covers ::od_optimize_template_output_buffer + */ + public function test_link_tag_html_fallback_always_output_for_oversized_link(): void { + $href = 'https://example.com/' . str_repeat( 'a', 5000 ) . '.jpg'; + $this->add_preload_link_at_finish( $href ); + + $buffer = od_optimize_template_output_buffer( '' ); + + $this->assertStringContainsString( $href, $buffer ); + } } From 381aaf81fd6e227a07dbd69339d56ded82dbac13 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 12 Jul 2026 22:32:21 +0600 Subject: [PATCH 2/2] fix(optimization-detective): omit imagesrcset/imagesizes from Link header Reworks the oversized Link preload header fix per reviewer feedback: instead of capping/skipping the header when too large, permanently strip imagesrcset/imagesizes from the Link response header since those attributes cause the bloat and web.dev now recommends relying on the HTML link tag for responsive image preloading anyway. - Remove od_get_maximum_link_response_header_length() and the od_link_response_header_max_length filter added previously; the header guard is back to its original simple form. - OD_Link_Collection::get_response_header() now omits imagesrcset and imagesizes; get_html() is unchanged and still emits them. - Update tests and docs accordingly. Addresses PR feedback. Refs #2572 --- .../class-od-link-collection.php | 32 +++------ plugins/optimization-detective/docs/hooks.md | 19 ----- .../optimization-detective/optimization.php | 46 +----------- .../tests/test-class-od-link-collection.php | 8 +-- .../tests/test-optimization.php | 71 ------------------- 5 files changed, 16 insertions(+), 160 deletions(-) diff --git a/plugins/optimization-detective/class-od-link-collection.php b/plugins/optimization-detective/class-od-link-collection.php index 7391711cff..cb7d3a4817 100644 --- a/plugins/optimization-detective/class-od-link-collection.php +++ b/plugins/optimization-detective/class-od-link-collection.php @@ -270,32 +270,22 @@ public function get_response_header(): ?string { if ( isset( $link['href'] ) ) { $link['href'] = $this->encode_url_for_response_header( $link['href'] ); } else { - // The about:blank is present since a Link without a reference-uri is invalid so any imagesrcset would otherwise not get downloaded. + // The about:blank is present since a Link without a reference-uri is invalid. $link['href'] = 'about:blank'; } - // Encode the URLs in the srcset. - if ( isset( $link['imagesrcset'] ) ) { - $link['imagesrcset'] = join( - ', ', - array_map( - function ( $image_candidate ) { - // Parse out the URL to separate it from the descriptor. - $image_candidate_parts = (array) preg_split( '/\s+/', (string) $image_candidate, 2 ); - - // Encode the URL. - $image_candidate_parts[0] = $this->encode_url_for_response_header( (string) $image_candidate_parts[0] ); - - // Re-join the URL with the descriptor. - return implode( ' ', $image_candidate_parts ); - }, - (array) preg_split( '/\s*,\s*/', $link['imagesrcset'] ) - ) - ); - } - $link_header = '<' . $link['href'] . '>'; unset( $link['href'] ); + + /* + * Omit imagesrcset/imagesizes from the Link response header. These are only relevant to the HTML + * tag (see get_html()) and are the primary contributor to oversized headers when responsive + * images have long or non-ASCII srcset URLs across multiple viewport breakpoints, which can cause + * some reverse proxies (e.g. Nginx) to reject the response with "upstream sent too big header". + * Per , the HTML + * element is the recommended way to preload responsive images anyway. + */ + unset( $link['imagesrcset'], $link['imagesizes'] ); foreach ( $link as $name => $value ) { /* * Escape the value being put into an HTTP quoted string. The grammar is: diff --git a/plugins/optimization-detective/docs/hooks.md b/plugins/optimization-detective/docs/hooks.md index 2667a48b44..208b245cea 100644 --- a/plugins/optimization-detective/docs/hooks.md +++ b/plugins/optimization-detective/docs/hooks.md @@ -414,25 +414,6 @@ Filters the maximum allowed size in bytes for a URL Metric serialized to JSON. The filtered value must be greater than zero; otherwise it will be ignored, and a usage warning will result. -### Filter: `od_link_response_header_max_length` (default: 4 KB in bytes) - -Filters the maximum allowed length in bytes for the `Link` response header used to preload images and other subresources discovered by Optimization Detective. - -Some reverse proxies (e.g. Nginx) fail a request with an error like "upstream sent too big header" if a response header grows too large, which can happen when there are long or non-ASCII `srcset` URLs across multiple viewport breakpoints. If the constructed `Link` header would exceed this length, it is omitted from the response, and only the equivalent `LINK` tags injected into the `HEAD` are relied on for preloading. - -The filtered value must be greater than zero; otherwise it will be ignored, and a usage warning will result. - -Sites that know their proxy/server can handle larger headers can raise (or remove) this cap: - -```php -add_filter( - 'od_link_response_header_max_length', - function (): int { - return 16 * KB_IN_BYTES; - } -); -``` - ### Filter: `od_gzip_url_metric_store_request_payloads` (default: `true` if the `gzdecode()` function exists) Filters whether URL Metric JSON data should be compressed with gzip when being submitted to the `/url-metrics:store` REST API endpoint. diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 6c44d6ee47..2317f26e16 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -171,46 +171,6 @@ function od_is_response_html_content_type(): bool { return $is_html_content_type; } -/** - * Gets the maximum allowed length in bytes for the Link response header. - * - * If the constructed Link header would exceed this length, it is omitted from the response entirely and only - * the equivalent LINK tags are relied on, since some reverse proxies (e.g. Nginx) fail with an error such as - * "upstream sent too big header" when a response header is too large. - * - * @since n.e.x.t - * @access private - * - * @return positive-int Maximum allowed byte length. - */ -function od_get_maximum_link_response_header_length(): int { - /** - * Filters the maximum allowed length in bytes for the Link response header. - * - * @since n.e.x.t - * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_link_response_header_max_length - * - * @param int $max_length Maximum allowed byte length. - * @return int Filtered maximum allowed byte length. - */ - $length = (int) apply_filters( 'od_link_response_header_max_length', 4 * KB_IN_BYTES ); - if ( $length <= 0 ) { - _doing_it_wrong( - esc_html( "Filter: 'od_link_response_header_max_length'" ), - esc_html( - sprintf( - /* translators: %s: length */ - __( 'Invalid length "%s". Must be greater than zero.', 'optimization-detective' ), - $length - ) - ), - 'Optimization Detective n.e.x.t' - ); - $length = 4 * KB_IN_BYTES; - } - return $length; -} - /** * Optimizes template output buffer. * @@ -398,11 +358,7 @@ function od_optimize_template_output_buffer( string $buffer ): string { // Additional links may have been added at the od_finish_template_optimization action, so this must come after. if ( count( $link_collection ) > 0 ) { $response_header_links = $link_collection->get_response_header(); - if ( - ! is_null( $response_header_links ) - && ! headers_sent() - && strlen( $response_header_links ) <= od_get_maximum_link_response_header_length() - ) { + if ( ! is_null( $response_header_links ) && ! headers_sent() ) { header( $response_header_links, false ); } $processor->append_head_html( $link_collection->get_html() ); diff --git a/plugins/optimization-detective/tests/test-class-od-link-collection.php b/plugins/optimization-detective/tests/test-class-od-link-collection.php index 4c5e538686..a87c59507a 100644 --- a/plugins/optimization-detective/tests/test-class-od-link-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-link-collection.php @@ -37,7 +37,7 @@ public function data_provider_to_test_add_link(): array { 'expected_html' => ' ', - 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="100vw"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"; type="image/jpeg"', + 'expected_header' => 'Link: ; rel="preload"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"; type="image/jpeg"', 'expected_count' => 1, 'error' => '', ), @@ -56,7 +56,7 @@ public function data_provider_to_test_add_link(): array { 'expected_html' => ' ', - 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="(max-width: 600px) 480px, 800px"; as="image"; media="screen"', + 'expected_header' => 'Link: ; rel="preload"; as="image"; media="screen"', 'expected_count' => 1, 'error' => '', ), @@ -268,7 +268,7 @@ public function data_provider_to_test_add_link(): array { 'expected_html' => ' ', - 'expected_header' => 'Link: ; rel="preload"; as="image"; fetchpriority="high"; imagesrcset="https://example.com/%22bar%22-480w.jpg 480w, https://example.com/%22bar%22-800w.jpg 800w"; imagesizes="(max-width: 600px) 480px, 800px"; crossorigin="anonymous"', + 'expected_header' => 'Link: ; rel="preload"; as="image"; fetchpriority="high"; crossorigin="anonymous"', 'expected_count' => 1, 'error' => '', ), @@ -469,7 +469,7 @@ public function data_provider_to_test_add_link(): array { 'expected_html' => ' ', - 'expected_header' => 'Link: ; rel="preload"; as="image"; imagesizes="(width <= 480px) 316px, (480px < width <= 600px) 489px, (600px < width <= 782px) 644px, (782px < width) 644px"; imagesrcset="https://example.com/wp-content/uploads/2025/02/%D8%A7%D9%84%D8%A8%D9%8A%D8%B3%D9%88%D9%86-1024x668-jpg.webp 1024w, https://example.com/wp-content/uploads/2025/02/%D8%A7%D9%84%D8%A8%D9%8A%D8%B3%D9%88%D9%86-300x196-jpg.webp 300w, https://example.com/wp-content/uploads/2025/02/%D8%A7%D9%84%D8%A8%D9%8A%D8%B3%D9%88%D9%86-768x501-jpg.webp 768w, https://example.com/wp-content/uploads/2025/02/%D8%A7%D9%84%D8%A8%D9%8A%D8%B3%D9%88%D9%86-1536x1002-jpg.webp 1536w, https://example.com/wp-content/uploads/2025/02/%D8%A7%D9%84%D8%A8%D9%8A%D8%B3%D9%88%D9%86-2048x1336-jpg.webp 2048w"', + 'expected_header' => 'Link: ; rel="preload"; as="image"', 'expected_count' => 1, 'error' => '', ), diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index c6ae4e13b1..b27f43efdc 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -548,75 +548,4 @@ function ( OD_Template_Optimization_Context $context ) use ( &$template_optimiza $this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_start_template_optimization' ) ); $this->assertSame( $did_initialize ? 1 : 0, did_action( 'od_finish_template_optimization' ) ); } - - /** - * Adds a preload link for the given href at the od_finish_template_optimization action. - * - * @param non-empty-string $href Href. - */ - private function add_preload_link_at_finish( string $href ): void { - add_action( - 'od_finish_template_optimization', - static function ( OD_Template_Optimization_Context $context ) use ( $href ): void { - $context->link_collection->add_link( - array( - 'rel' => 'preload', - 'href' => $href, - 'as' => 'image', - ) - ); - } - ); - } - - /** - * Tests the default value for the maximum Link response header length. - * - * @covers ::od_get_maximum_link_response_header_length - */ - public function test_od_get_maximum_link_response_header_length_default(): void { - $this->assertSame( 4 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); - } - - /** - * Tests that the od_link_response_header_max_length filter can change the maximum Link response header length. - * - * @covers ::od_get_maximum_link_response_header_length - */ - public function test_od_get_maximum_link_response_header_length_filter(): void { - add_filter( - 'od_link_response_header_max_length', - static function (): int { - return 8 * KB_IN_BYTES; - } - ); - $this->assertSame( 8 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); - } - - /** - * Tests that an invalid od_link_response_header_max_length filter value falls back to the default. - * - * @covers ::od_get_maximum_link_response_header_length - */ - public function test_od_get_maximum_link_response_header_length_invalid_value(): void { - add_filter( 'od_link_response_header_max_length', '__return_zero' ); - $this->setExpectedIncorrectUsage( 'Filter: 'od_link_response_header_max_length'' ); - - $this->assertSame( 4 * KB_IN_BYTES, od_get_maximum_link_response_header_length() ); - } - - /** - * Tests that the HTML link tag fallback is output even when the corresponding Link response header would be - * oversized, so preloading is never lost even when the header is skipped. - * - * @covers ::od_optimize_template_output_buffer - */ - public function test_link_tag_html_fallback_always_output_for_oversized_link(): void { - $href = 'https://example.com/' . str_repeat( 'a', 5000 ) . '.jpg'; - $this->add_preload_link_at_finish( $href ); - - $buffer = od_optimize_template_output_buffer( '' ); - - $this->assertStringContainsString( $href, $buffer ); - } }