Skip to content
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp.php
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,9 @@ public function send_headers() {
/**
* Fires once the requested HTTP headers for caching, content type, etc. have been sent.
*
* The {@see 'wp_send_late_headers'} action may be used to send headers after rendering the template into an
* output buffer.
*
* @since 2.1.0
*
* @param WP $wp Current WordPress environment instance (passed by reference).
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@
add_action( 'do_all_pings', 'generic_ping', 10, 0 );
add_action( 'do_robots', 'do_robots' );
add_action( 'do_favicon', 'do_favicon' );
add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer' );
add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer', 1000 ); // Late priority to let `wp_template_enhancement_output_buffer` filters and `wp_send_late_headers` actions be registered.
add_action( 'set_comment_cookies', 'wp_set_comment_cookies', 10, 3 );
add_action( 'sanitize_comment_cookies', 'sanitize_comment_cookies' );
add_action( 'init', 'smilies_init', 5 );
Expand Down
33 changes: 27 additions & 6 deletions src/wp-includes/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -844,16 +844,17 @@ function wp_should_output_buffer_template_for_enhancement(): bool {
* Filters whether the template should be output-buffered for enhancement.
*
* By default, an output buffer is only started if a {@see 'wp_template_enhancement_output_buffer'} filter has been
* added. For this default to apply, a filter must be added by the time the template is included at the
* {@see 'wp_before_include_template'} action. This allows template responses to be streamed as much as possible
* when no template enhancements are registered to apply. This filter allows a site to opt in to adding such
* template enhancement filters during the rendering of the template.
* added or if a plugin has added a {@see 'wp_send_late_headers'} action. For this default to apply, either of the
* hooks must be added by the time the template is included at the {@see 'wp_before_include_template'} action. This
* allows template responses to be streamed unless the there is code which depends on an output buffer being opened.
* This filter allows a site to opt in to adding such template enhancement filters later during the rendering of the
* template.
*
* @since 6.9.0
*
* @param bool $use_output_buffer Whether an output buffer is started.
*/
return (bool) apply_filters( 'wp_should_output_buffer_template_for_enhancement', has_filter( 'wp_template_enhancement_output_buffer' ) );
return (bool) apply_filters( 'wp_should_output_buffer_template_for_enhancement', has_filter( 'wp_template_enhancement_output_buffer' ) || has_action( 'wp_send_late_headers' ) );
}

/**
Expand Down Expand Up @@ -921,6 +922,21 @@ function wp_start_template_enhancement_output_buffer(): bool {
* @return string Finalized output buffer.
*/
function wp_finalize_template_enhancement_output_buffer( string $output, int $phase ): string {
$do_send_late_headers = static function ( string $output ): void {
/**
* Fires at the last moment HTTP headers may be sent.
*
* This happens immediately before the template enhancement output buffer is flushed. This is in contrast with
* the {@see 'send_headers'} action which fires after the initial headers have been sent before the template
* has begun rendering, and thus does not depend on output buffering.
*
* @since 6.9.0
*
* @param string $output Output buffer.
*/
do_action( 'wp_send_late_headers', $output );
};

// When the output is being cleaned (e.g. pending template is replaced with error page), do not send it through the filter.
if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) {
return $output;
Expand Down Expand Up @@ -957,6 +973,7 @@ function wp_finalize_template_enhancement_output_buffer( string $output, int $ph

// If the content type is not HTML, short-circuit since it is not relevant for enhancement.
if ( ! $is_html_content_type ) {
$do_send_late_headers( $output );
return $output;
}

Expand All @@ -977,5 +994,9 @@ function wp_finalize_template_enhancement_output_buffer( string $output, int $ph
* @param string $filtered_output HTML template enhancement output buffer.
* @param string $output Original HTML template output buffer.
*/
return (string) apply_filters( 'wp_template_enhancement_output_buffer', $filtered_output, $output );
$filtered_output = (string) apply_filters( 'wp_template_enhancement_output_buffer', $filtered_output, $output );

$do_send_late_headers( $filtered_output );

return $filtered_output;
}
102 changes: 87 additions & 15 deletions tests/phpunit/tests/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ public function test_wp_start_template_enhancement_output_buffer_begins_without_
* Tests that wp_start_template_enhancement_output_buffer() does not start a buffer even when there are filters present due to override.
*
* @ticket 43258
*
* @covers ::wp_should_output_buffer_template_for_enhancement
* @covers ::wp_start_template_enhancement_output_buffer
*/
Expand All @@ -617,19 +618,34 @@ static function () {
* an HTML document and that the response is not incrementally flushable.
*
* @ticket 43258
* @ticket 64126
*
* @covers ::wp_start_template_enhancement_output_buffer
* @covers ::wp_finalize_template_enhancement_output_buffer
*/
public function test_wp_start_template_enhancement_output_buffer_for_html(): void {
// Start a wrapper output buffer so that we can flush the inner buffer.
ob_start();

$filter_args = null;
$mock_filter_callback = new MockAction();
add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ) use ( &$filter_args ): string {
$filter_args = func_get_args();
array( $mock_filter_callback, 'filter' ),
10,
PHP_INT_MAX
);

$mock_action_callback = new MockAction();
add_filter(
'wp_send_late_headers',
array( $mock_action_callback, 'action' ),
10,
PHP_INT_MAX
);

add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ): string {
$p = WP_HTML_Processor::create_full_parser( $buffer );
while ( $p->next_tag() ) {
switch ( $p->get_tag() ) {
Expand All @@ -647,9 +663,7 @@ static function ( string $buffer ) use ( &$filter_args ): string {
}
}
return $p->get_updated_html();
},
10,
PHP_INT_MAX
}
);

$this->assertCount( 0, headers_list(), 'Expected no headers to have been sent during unit tests.' );
Expand Down Expand Up @@ -686,6 +700,8 @@ static function ( string $buffer ) use ( &$filter_args ): string {
ob_end_flush(); // End the buffer started by wp_start_template_enhancement_output_buffer().
$this->assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' );

$this->assertSame( 1, $mock_filter_callback->get_call_count(), 'Expected the wp_template_enhancement_output_buffer filter to have applied.' );
$filter_args = $mock_filter_callback->get_args()[0];
$this->assertIsArray( $filter_args, 'Expected the wp_template_enhancement_output_buffer filter to have applied.' );
$this->assertCount( 2, $filter_args, 'Expected two args to be supplied to the wp_template_enhancement_output_buffer filter.' );
$this->assertIsString( $filter_args[0], 'Expected the $filtered_output param to the wp_template_enhancement_output_buffer filter to be a string.' );
Expand All @@ -707,25 +723,36 @@ static function ( string $buffer ) use ( &$filter_args ): string {
$this->assertStringContainsString( '<title>Saludo</title>', $processed_output, 'Expected processed output to contain string.' );
$this->assertStringContainsString( '<h1>¡Hola, mundo!</h1>', $processed_output, 'Expected processed output to contain string.' );
$this->assertStringContainsString( '</html>', $processed_output, 'Expected processed output to contain string.' );

$this->assertSame( 1, did_action( 'wp_send_late_headers' ), 'Expected the wp_send_late_headers action to have fired.' );
$this->assertSame( 1, $mock_action_callback->get_call_count(), 'Expected wp_send_late_headers action callback to have been called once.' );
$action_args = $mock_action_callback->get_args()[0];
$this->assertCount( 1, $action_args, 'Expected the wp_send_late_headers action to have been passed only one argument.' );
$this->assertSame( $processed_output, $action_args[0], 'Expected the arg passed to wp_send_late_headers to be the same as the processed output buffer.' );
}

/**
* Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer but ending with cleaning prevents any processing.
*
* @ticket 43258
* @ticket 64126
*
* @covers ::wp_start_template_enhancement_output_buffer
* @covers ::wp_finalize_template_enhancement_output_buffer
*/
public function test_wp_start_template_enhancement_output_buffer_ended_cleaned(): void {
// Start a wrapper output buffer so that we can flush the inner buffer.
ob_start();

$applied_filter = false;
$mock_filter_callback = new MockAction();
add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ) use ( &$applied_filter ): string {
$applied_filter = true;
array( $mock_filter_callback, 'filter' )
);

add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ): string {
$p = WP_HTML_Processor::create_full_parser( $buffer );
if ( $p->next_tag( array( 'tag_name' => 'TITLE' ) ) ) {
$p->set_modifiable_text( 'Processed' );
Expand All @@ -734,6 +761,14 @@ static function ( string $buffer ) use ( &$applied_filter ): string {
}
);

$mock_action_callback = new MockAction();
add_filter(
'wp_send_late_headers',
array( $mock_action_callback, 'action' ),
10,
PHP_INT_MAX
);

$this->assertCount( 0, headers_list(), 'Expected no headers to have been sent during unit tests.' );
ini_set( 'default_mimetype', 'text/html' ); // Since sending a header won't work.

Expand Down Expand Up @@ -765,34 +800,41 @@ static function ( string $buffer ) use ( &$applied_filter ): string {

$this->assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' );

$this->assertFalse( $applied_filter, 'Expected the wp_template_enhancement_output_buffer filter to not have applied.' );
$this->assertSame( 0, did_action( 'wp_final_template_output_buffer' ), 'Expected the wp_final_template_output_buffer action to not have fired.' );
$this->assertSame( 0, $mock_filter_callback->get_call_count(), 'Expected the wp_template_enhancement_output_buffer filter to not have applied.' );

// Obtain the output via the wrapper output buffer.
$output = ob_get_clean();
$this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' );
$this->assertStringNotContainsString( '<title>Unprocessed</title>', $output, 'Expected output buffer to not have string since the template was overridden.' );
$this->assertStringNotContainsString( '<title>Processed</title>', $output, 'Expected output buffer to not have string since the filter did not apply.' );
$this->assertStringContainsString( '<title>Output Buffer Not Processed</title>', $output, 'Expected output buffer to have string since the output buffer was ended with cleaning.' );

$this->assertSame( 0, did_action( 'wp_send_late_headers' ), 'Expected the wp_send_late_headers action to not have fired.' );
$this->assertSame( 0, $mock_action_callback->get_call_count(), 'Expected wp_send_late_headers action callback to have been called once.' );
}

/**
* Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer and cleaning allows the template to be replaced.
*
* @ticket 43258
* @ticket 64126
*
* @covers ::wp_start_template_enhancement_output_buffer
* @covers ::wp_finalize_template_enhancement_output_buffer
*/
public function test_wp_start_template_enhancement_output_buffer_cleaned_and_replaced(): void {
// Start a wrapper output buffer so that we can flush the inner buffer.
ob_start();

$called_filter = false;
$mock_filter_callback = new MockAction();
add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ) use ( &$called_filter ): string {
$called_filter = true;
array( $mock_filter_callback, 'filter' )
);

add_filter(
'wp_template_enhancement_output_buffer',
static function ( string $buffer ): string {
$p = WP_HTML_Processor::create_full_parser( $buffer );
if ( $p->next_tag( array( 'tag_name' => 'TITLE' ) ) ) {
$p->set_modifiable_text( 'Processed' );
Expand All @@ -801,6 +843,14 @@ static function ( string $buffer ) use ( &$called_filter ): string {
}
);

$mock_action_callback = new MockAction();
add_filter(
'wp_send_late_headers',
array( $mock_action_callback, 'action' ),
10,
PHP_INT_MAX
);

$this->assertCount( 0, headers_list(), 'Expected no headers to have been sent during unit tests.' );
ini_set( 'default_mimetype', 'application/xhtml+xml' ); // Since sending a header won't work.

Expand Down Expand Up @@ -837,20 +887,28 @@ static function ( string $buffer ) use ( &$called_filter ): string {
ob_end_flush(); // End the buffer started by wp_start_template_enhancement_output_buffer().
$this->assertSame( $initial_ob_level, ob_get_level(), 'Expected the output buffer to be back at the initial level.' );

$this->assertTrue( $called_filter, 'Expected the wp_template_enhancement_output_buffer filter to have applied.' );
$this->assertSame( 1, $mock_filter_callback->get_call_count(), 'Expected the wp_template_enhancement_output_buffer filter to have applied.' );

// Obtain the output via the wrapper output buffer.
$output = ob_get_clean();
$this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' );
$this->assertStringNotContainsString( '<title>Unprocessed</title>', $output, 'Expected output buffer to not have string due to template override.' );
$this->assertStringContainsString( '<title>Processed</title>', $output, 'Expected output buffer to have string due to filtering.' );
$this->assertStringContainsString( '<h1>Template Replaced</h1>', $output, 'Expected output buffer to have string due to replaced template.' );

$this->assertSame( 1, did_action( 'wp_send_late_headers' ), 'Expected the wp_send_late_headers action to have fired.' );
$this->assertSame( 1, $mock_action_callback->get_call_count(), 'Expected wp_send_late_headers action callback to have been called once.' );
$action_args = $mock_action_callback->get_args()[0];
$this->assertCount( 1, $action_args, 'Expected the wp_send_late_headers action to have been passed only one argument.' );
$this->assertSame( $output, $action_args[0], 'Expected the arg passed to wp_send_late_headers to be the same as the processed output buffer.' );
}

/**
* Tests that wp_start_template_enhancement_output_buffer() starts the expected output buffer and that the output buffer is not processed.
*
* @ticket 43258
* @ticket 64126
*
* @covers ::wp_start_template_enhancement_output_buffer
* @covers ::wp_finalize_template_enhancement_output_buffer
*/
Expand All @@ -861,6 +919,14 @@ public function test_wp_start_template_enhancement_output_buffer_for_json(): voi
$mock_filter_callback = new MockAction();
add_filter( 'wp_template_enhancement_output_buffer', array( $mock_filter_callback, 'filter' ) );

$mock_action_callback = new MockAction();
add_filter(
'wp_send_late_headers',
array( $mock_action_callback, 'action' ),
10,
PHP_INT_MAX
);

$initial_ob_level = ob_get_level();
$this->assertTrue( wp_start_template_enhancement_output_buffer(), 'Expected wp_start_template_enhancement_output_buffer() to return true indicating the output buffer started.' );
$this->assertSame( 1, did_action( 'wp_template_enhancement_output_buffer_started' ), 'Expected the wp_template_enhancement_output_buffer_started action to have fired.' );
Expand Down Expand Up @@ -894,6 +960,12 @@ public function test_wp_start_template_enhancement_output_buffer_for_json(): voi
$output = ob_get_clean();
$this->assertIsString( $output, 'Expected ob_get_clean() to return a string.' );
$this->assertSame( $json, $output, 'Expected output to not be processed.' );

$this->assertSame( 1, did_action( 'wp_send_late_headers' ), 'Expected the wp_send_late_headers action to have fired even though the wp_template_enhancement_output_buffer filter did not apply.' );
$this->assertSame( 1, $mock_action_callback->get_call_count(), 'Expected wp_send_late_headers action callback to have been called once.' );
$action_args = $mock_action_callback->get_args()[0];
$this->assertCount( 1, $action_args, 'Expected the wp_send_late_headers action to have been passed only one argument.' );
$this->assertSame( $output, $action_args[0], 'Expected the arg passed to wp_send_late_headers to be the same as the processed output buffer.' );
}

/**
Expand Down
Loading