From 2de2f0a26c5ed2ca8551c2374245f35e9e08d1e0 Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Thu, 4 Jun 2026 15:03:59 +0200 Subject: [PATCH] Optimization Detective: Reuse the WordPress 6.9 template enhancement output buffer. Instead of opening its own output buffer at the template_include filter via od_buffer_output() and exposing the od_template_output_buffer filter, the optimization callback is now registered on the wp_template_enhancement_output_buffer filter introduced in WordPress 6.9 (Core-43258, r60936). Registering the filter also opts in to starting the core output buffer; when the response is not eligible for optimization, no buffer is started and the response is streamed. This removes the od_buffer_output() function, the template_include hook, and the plugin-specific od_template_output_buffer filter (documented removal). Requires the minimum WordPress version to be 6.9. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/optimization-detective/docs/hooks.md | 6 - .../docs/introduction.md | 2 +- plugins/optimization-detective/hooks.php | 1 - .../optimization-detective/optimization.php | 86 +++-------- .../tests/test-hooks.php | 1 - .../tests/test-optimization.php | 140 +++++------------- 6 files changed, 59 insertions(+), 177 deletions(-) diff --git a/plugins/optimization-detective/docs/hooks.md b/plugins/optimization-detective/docs/hooks.md index 208b245cea..1d3e93c6d0 100644 --- a/plugins/optimization-detective/docs/hooks.md +++ b/plugins/optimization-detective/docs/hooks.md @@ -310,12 +310,6 @@ add_filter( 'od_maximum_viewport_aspect_ratio', static function (): int { } ); ``` -### Filter: `od_template_output_buffer` (default: the HTML response) - -Filters the template output buffer before sending it to the client. - -This filter is added to implement [\#43258](https://core.trac.wordpress.org/ticket/43258) in WordPress core. - ### Filter: `od_url_metric_schema_element_item_additional_properties` (default: empty array) Filters additional schema properties which should be allowed for an element's item in a URL Metric. diff --git a/plugins/optimization-detective/docs/introduction.md b/plugins/optimization-detective/docs/introduction.md index d53aa7cfa3..5bb662bd4d 100644 --- a/plugins/optimization-detective/docs/introduction.md +++ b/plugins/optimization-detective/docs/introduction.md @@ -74,7 +74,7 @@ The implementation involves two phases: detection and optimization. Both of thes The detection phase involves collecting metrics about the viewport and the elements on the page and then submitting this data to the site’s REST API: 1. Extensions register tag visitors which opt in to measurement for the elements they target for optimization. (As elaborated in a section below, tag visitors are simply callbacks which are invoked for every open tag.) -2. The rendered page is captured in an output buffer (which starts at the `template_include` filter with the highest priority). Output buffering ensures that optimizations can be applied to classic themes, block themes, or even completely custom templating systems (e.g. Timber). +2. The rendered page is captured in WordPress 6.9's template enhancement output buffer (via the `wp_template_enhancement_output_buffer` filter). Output buffering ensures that optimizations can be applied to classic themes, block themes, or even completely custom templating systems (e.g. Timber). 3. The rendered page is loaded into an HTML Tag processor instance ([introduced](https://make.wordpress.org/core/2023/03/07/introducing-the-html-api-in-wordpress-6-2/) in WP 6.2). 4. The open tags are iterated over, and all registered tag visitors are invoked for each open tag, giving them the opportunity to opt in the tag for measurement via the `track_tag()` method on the tag visitor context object. A tag which is opted in for measurement will get a `data-od-xpath` attribute added to it. 5. The detection script module is added to the frontend (`detect.js`). diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 18b0ad1f05..3d6bed312c 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -18,7 +18,6 @@ // @codeCoverageIgnoreStart add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX ); -add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX ); OD_URL_Metrics_Post_Type::add_hooks(); OD_Storage_Lock::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index a604d4b476..fb0478a31a 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -14,72 +14,18 @@ } // @codeCoverageIgnoreEnd -/** - * Starts output buffering at the end of the 'template_include' filter. - * - * This is to implement #43258 in core. - * - * This is a hack that would eventually be replaced with something like this in wp-includes/template-loader.php: - * - * $template = apply_filters( 'template_include', $template ); - * + ob_start( 'wp_template_output_buffer_callback' ); - * if ( $template ) { - * include $template; - * } elseif ( current_user_can( 'switch_themes' ) ) { - * - * @since 0.1.0 - * @access private - * @link https://core.trac.wordpress.org/ticket/43258 - * - * @param string|mixed $passthrough Value for the template_include filter which is passed through. - * @return string|mixed Unmodified value of $passthrough. - */ -function od_buffer_output( $passthrough ) { - /* - * Instead of the default PHP_OUTPUT_HANDLER_STDFLAGS (cleanable, flushable, and removable) being used for flags, - * we need to omit PHP_OUTPUT_HANDLER_FLUSHABLE. If the buffer were flushable, then each time that ob_flush() is - * called, it would send a fragment of the output into the output buffer callback. When buffering the entire - * response as an HTML document, this would result in broken HTML processing. - * - * If this ends up being problematic, then PHP_OUTPUT_HANDLER_FLUSHABLE could be added to the $flags and the - * output buffer callback could check if the phase is PHP_OUTPUT_HANDLER_FLUSH and abort any later - * processing while also emitting a _doing_it_wrong(). - * - * The output buffer needs to be removable because WordPress calls wp_ob_end_flush_all() and then calls - * wp_cache_close(). If the buffers are not all flushed before wp_cache_close() is closed, then some output buffer - * handlers (e.g. for caching plugins) may fail to be able to store the page output in the object cache. - * See . - */ - $flags = PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE; - - ob_start( - static function ( string $output, ?int $phase ): string { - // When the output is being cleaned (e.g. the pending template is replaced with an error page), do not send it through the filter. - if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) { - return $output; - } - - /** - * Filters the template output buffer before sending it to the client. - * - * @since 0.1.0 - * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_template_output_buffer - * - * @param string $output Output buffer. - * @return string Filtered output buffer. - */ - return (string) apply_filters( 'od_template_output_buffer', $output ); - }, - 0, // Unlimited buffer size. - $flags - ); - return $passthrough; -} - /** * Adds template output buffer filter for optimization if eligible. * + * Since WordPress 6.9, this registers the optimization callback on the {@see 'wp_template_enhancement_output_buffer'} + * filter (Core-43258, see r60936) so that the core-managed template enhancement output buffer is reused instead of + * Optimization Detective starting its own. Registering the filter also opts in to starting the core output buffer + * (see {@see wp_should_output_buffer_template_for_enhancement()}); when the response is not eligible for optimization, + * no filter is added and the response is streamed. + * * @since 0.1.0 + * @since n.e.x.t Registers on the WordPress 6.9 `wp_template_enhancement_output_buffer` filter instead of the + * plugin's own `od_template_output_buffer` filter. * @access private */ function od_maybe_add_template_output_buffer_filter(): void { @@ -106,7 +52,21 @@ function_exists( 'perflab_server_timing_use_output_buffer' ) ) { $callback = perflab_wrap_server_timing( $callback, 'optimization-detective', 'exist' ); } - add_filter( 'od_template_output_buffer', $callback ); + add_filter( 'wp_template_enhancement_output_buffer', $callback ); + + // Backward compatibility: continue to apply the deprecated `od_template_output_buffer` filter for any third-party + // callbacks still hooked to it. This only emits a deprecation notice (and only runs) when the filter has listeners. + add_filter( + 'wp_template_enhancement_output_buffer', + static function ( string $filtered_output ): string { + return (string) apply_filters_deprecated( + 'od_template_output_buffer', + array( $filtered_output ), + 'Optimization Detective 1.0.0', + 'wp_template_enhancement_output_buffer' + ); + } + ); } /** diff --git a/plugins/optimization-detective/tests/test-hooks.php b/plugins/optimization-detective/tests/test-hooks.php index 6e1fa19efe..5cc71de09d 100644 --- a/plugins/optimization-detective/tests/test-hooks.php +++ b/plugins/optimization-detective/tests/test-hooks.php @@ -14,7 +14,6 @@ class Test_OD_Hooks extends WP_UnitTestCase { */ public function test_hooks_added(): void { $this->assertEquals( PHP_INT_MAX, has_action( 'init', 'od_initialize_extensions' ) ); - $this->assertEquals( PHP_INT_MAX, has_filter( 'template_include', 'od_buffer_output' ) ); $this->assertEquals( 10, has_filter( 'wp', 'od_maybe_add_template_output_buffer_filter' ) ); $this->assertEquals( 10, has_action( 'wp_head', 'od_render_generator_meta_tag' ) ); diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index b27f43efdc..7d8707ff01 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -41,109 +41,6 @@ public function tear_down(): void { parent::tear_down(); } - /** - * Make output is buffered and that it is also filtered. - * - * @covers ::od_buffer_output - */ - public function test_od_buffer_output(): void { - $original = 'Hello World!'; - $expected = '¡Hola Mundo!'; - - // To test, a wrapping output buffer is required because ob_get_clean() does not invoke the output - // buffer callback. See . - ob_start(); - - $filter_invoked = false; - add_filter( - 'od_template_output_buffer', - function ( $buffer ) use ( $original, $expected, &$filter_invoked ) { - $this->assertSame( $original, $buffer ); - $filter_invoked = true; - return $expected; - } - ); - - $original_ob_level = ob_get_level(); - $template = sprintf( 'page-%s.php', wp_generate_uuid4() ); - $this->assertSame( $template, od_buffer_output( $template ), 'Expected value to be passed through.' ); - $this->assertSame( $original_ob_level + 1, ob_get_level(), 'Expected call to ob_start().' ); - echo $original; - - ob_end_flush(); // Flushing invokes the output buffer callback. - - $buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer. - $this->assertSame( $expected, $buffer ); - $this->assertTrue( $filter_invoked ); - } - - /** - * Test that calling ob_flush() will not result in the buffer being processed and that ob_clean() will successfully prevent content from being processed. - * - * @covers ::od_buffer_output - */ - public function test_od_buffer_with_cleaning_and_attempted_flushing(): void { - $template_aborted = 'Before time began!'; - $template_start = 'The beginning'; - $template_middle = ', the middle'; - $template_end = ', and the end!'; - - // To test, a wrapping output buffer is required because ob_get_clean() does not invoke the output - // buffer callback. See . - $initial_level = ob_get_level(); - $this->assertTrue( ob_start() ); - $this->assertSame( $initial_level + 1, ob_get_level() ); - - $filter_count = 0; - add_filter( - 'od_template_output_buffer', - function ( $buffer ) use ( $template_start, $template_middle, $template_end, &$filter_count ) { - $filter_count++; - $this->assertSame( $template_start . $template_middle . $template_end, $buffer ); - return '' . $buffer . ''; - } - ); - - od_buffer_output( '' ); - $this->assertSame( $initial_level + 2, ob_get_level() ); - - echo $template_aborted; - $this->assertTrue( ob_clean() ); // By cleaning, the above should never be seen by the filter. - - // This is the start of what will end up getting filtered. - echo $template_start; - - // Attempt to flush the output, which will fail because the output buffer was opened without the flushable flag. - $this->assertFalse( ob_flush() ); - - // This will also be sent into the filter. - echo $template_middle; - $this->assertFalse( ob_flush() ); - $this->assertSame( $initial_level + 2, ob_get_level() ); - - // Start a nested output buffer which will also end up getting sent into the filter. - $this->assertTrue( ob_start() ); - echo $template_end; - $this->assertSame( $initial_level + 3, ob_get_level() ); - $this->assertTrue( ob_flush() ); - $this->assertTrue( ob_end_flush() ); - $this->assertSame( $initial_level + 2, ob_get_level() ); - - // Close the output buffer opened by od_buffer_output(). This only works in the unit test because the removable flag was passed. - $this->assertTrue( ob_end_flush() ); - $this->assertSame( $initial_level + 1, ob_get_level() ); - - $buffer = ob_get_clean(); // Get the buffer from our wrapper output buffer and close it. - $this->assertSame( $initial_level, ob_get_level() ); - - $this->assertSame( 1, $filter_count, 'Expected filter to be called once.' ); - $this->assertSame( - '' . $template_start . $template_middle . $template_end . '', - $buffer, - 'Excepted return value of filter to be the resulting value for the buffer.' - ); - } - /** * Data provider. * @@ -237,10 +134,43 @@ public function test_od_maybe_add_template_output_buffer_filter( Closure $set_up $url = $set_up(); $this->go_to( $url ); - remove_all_filters( 'od_template_output_buffer' ); // In case go_to() caused them to be added. + remove_all_filters( 'wp_template_enhancement_output_buffer' ); // In case go_to() caused them to be added. od_maybe_add_template_output_buffer_filter(); - $this->assertSame( $expected_has_filter, has_filter( 'od_template_output_buffer' ) ); + $this->assertSame( $expected_has_filter, has_filter( 'wp_template_enhancement_output_buffer' ) ); + } + + /** + * Test that the deprecated od_template_output_buffer filter is still applied for backward compatibility. + * + * @covers ::od_maybe_add_template_output_buffer_filter + * + * @expectedDeprecated od_template_output_buffer + */ + public function test_od_template_output_buffer_filter_backward_compatibility(): void { + self::factory()->post->create(); + $this->go_to( home_url( '/' ) ); + remove_all_filters( 'wp_template_enhancement_output_buffer' ); + remove_all_filters( 'od_template_output_buffer' ); + add_filter( 'od_can_optimize_response', '__return_true' ); + + od_maybe_add_template_output_buffer_filter(); + $this->assertTrue( has_filter( 'wp_template_enhancement_output_buffer' ), 'Expected the core enhancement filter to be added.' ); + + $invoked = false; + add_filter( + 'od_template_output_buffer', + static function ( string $buffer ) use ( &$invoked ): string { + $invoked = true; + return $buffer . ''; + } + ); + + $output = ''; + $filtered = (string) apply_filters( 'wp_template_enhancement_output_buffer', $output, $output ); + + $this->assertTrue( $invoked, 'Expected the deprecated od_template_output_buffer filter to still be applied.' ); + $this->assertStringContainsString( '', $filtered ); } /**