From 814f50533db98920b3142e95e2c157bdcbb3c2c1 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 7 Aug 2026 14:18:43 +0200 Subject: [PATCH 1/2] Interactivity API: mark server-rendered style assets with data-wp-router-managed. --- .../class-wp-interactivity-api.php | 193 +++++++ .../wpInteractivityAPI-wp-router-managed.php | 525 ++++++++++++++++++ tests/phpunit/tests/template.php | 3 + 3 files changed, 721 insertions(+) create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-managed.php diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index 62bfc29cfc03f..dd838edd92bb2 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -95,6 +95,20 @@ final class WP_Interactivity_API { */ private $has_processed_router_region = false; + /** + * Flag that indicates whether all the blocks rendered on the page support + * client-side navigation. + * + * It starts as `true` and it is set to `false` as soon as a block that does + * not declare support for client-side navigation is rendered. It is used to + * decide whether the server-generated style assets can be marked with the + * `data-wp-router-managed` attribute. + * + * @since 7.2.0 + * @var bool + */ + private $all_blocks_support_client_navigation = true; + /** * Set of script modules that can be loaded after client-side navigation. * @@ -399,11 +413,179 @@ public function register_script_modules() { * * @since 6.5.0 * @since 6.9.0 Adds support for client-side navigation in script modules. + * @since 7.2.0 Tracks the page-wide client-side navigation support and adds the `data-wp-router-managed` + * attribute to server-generated style assets. */ public function add_hooks() { add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) ); add_filter( 'script_module_data_@wordpress/interactivity-router', array( $this, 'filter_script_module_interactivity_router_data' ) ); add_filter( 'wp_script_attributes', array( $this, 'add_load_on_client_navigation_attribute_to_script_modules' ) ); + + if ( ! is_admin() ) { + /* + * The tracked support is only read on the front end, so there is no + * need to inspect every rendered block in admin requests. + */ + add_filter( 'render_block_data', array( $this, 'filter_render_block_data_client_navigation_support' ) ); + + /* + * The priority is set to 20 so this filter runs after the one added by + * `wp_hoist_late_printed_styles()`, which uses the default priority and + * can move style tags into the HEAD. That way, every style asset present + * in the final markup gets the attribute. + * + * Note that adding this filter is what makes core start the template + * enhancement output buffer, as documented in + * `wp_should_output_buffer_template_for_enhancement()`. + */ + add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'filter_template_output_buffer_add_router_managed_attribute' ), 20 ); + } + } + + /** + * Tracks whether all the blocks rendered on the page support client-side + * navigation. + * + * This method is a `render_block_data` filter callback that only inspects the + * blocks being rendered; it always returns the parsed block unmodified. As + * soon as a block that does not declare support for client-side navigation is + * found, client-side navigation is considered unsupported for the whole page. + * + * The compatibility rules mirror the ones used by + * {@see block_core_query_disable_enhanced_pagination()}: blocks without a + * block name, i.e. freeform classic HTML, do not break compatibility, while + * named blocks require either `supports.interactivity` or + * `supports.interactivity.clientNavigation` to be `true`. + * + * @since 7.2.0 + * + * @param array $parsed_block The block being rendered. + * @return array Returns the parsed block, unmodified. + */ + public function filter_render_block_data_client_navigation_support( $parsed_block ) { + if ( ! $this->all_blocks_support_client_navigation ) { + return $parsed_block; + } + + if ( ! isset( $parsed_block['blockName'] ) ) { + return $parsed_block; + } + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $parsed_block['blockName'] ); + + /* + * Client side navigation can be true in two states: + * - supports.interactivity = true; + * - supports.interactivity.clientNavigation = true; + */ + $supports_client_navigation = ( isset( $block_type->supports['interactivity']['clientNavigation'] ) && true === $block_type->supports['interactivity']['clientNavigation'] ) + || ( isset( $block_type->supports['interactivity'] ) && true === $block_type->supports['interactivity'] ); + + if ( ! $supports_client_navigation ) { + $this->all_blocks_support_client_navigation = false; + } + + return $parsed_block; + } + + /** + * Adds the `data-wp-router-managed` attribute to all the style assets of the + * page. + * + * This method is a `wp_template_enhancement_output_buffer` filter callback, so + * it receives the complete server-generated markup. Working on the final + * buffer is what guarantees full-page coverage: every ` + + + + + + + +HTML; + } + + /** + * Returns the value of the `data-wp-router-managed` attribute for every tag + * of the given markup, keyed by the order in which the tags are found. + * + * @param string $html The markup to inspect. + * @param array $query The tag query, as accepted by `WP_HTML_Tag_Processor::next_tag()`. + * @return array The attribute values. + */ + protected function get_managed_attributes( string $html, array $query ): array { + $values = array(); + $p = new WP_HTML_Tag_Processor( $html ); + while ( $p->next_tag( $query ) ) { + $values[] = $p->get_attribute( 'data-wp-router-managed' ); + } + return $values; + } + + /** + * Tests that the attribute is added to all the style assets when all the + * conditions are met. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_added_to_style_assets() { + $this->process_router_region(); + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $this->get_page_html() ); + + // Both style tags are marked. + $this->assertSame( array( true, true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + + // Only the stylesheet link is marked. + $this->assertSame( array( true, null ), $this->get_managed_attributes( $html, array( 'tag_name' => 'link' ) ) ); + + // The script tag is untouched. + $this->assertSame( array( null ), $this->get_managed_attributes( $html, array( 'tag_name' => 'script' ) ) ); + + // The attribute is rendered in its empty, valueless form. + $this->assertStringContainsString( 'data-wp-router-managed', $html ); + $this->assertStringNotContainsString( 'data-wp-router-managed=', $html ); + $this->assertSame( 3, substr_count( $html, 'data-wp-router-managed' ) ); + } + + /** + * Tests that the buffer is not modified when no router region has been + * processed. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_without_router_region() { + $buffer = $this->get_page_html(); + + $this->assertSame( $buffer, $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ) ); + } + + /** + * Tests that the buffer is not modified when client-side navigation is + * disabled in the `core/router` config. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_when_client_navigation_is_disabled() { + $this->process_router_region(); + $this->interactivity->config( 'core/router', array( 'clientNavigationDisabled' => true ) ); + + $buffer = $this->get_page_html(); + + $this->assertSame( $buffer, $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ) ); + } + + /** + * Tests that the buffer is not modified when a block that does not support + * client-side navigation has been rendered. + * + * @covers ::filter_render_block_data_client_navigation_support + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_with_a_block_without_client_navigation_support() { + $this->process_router_region(); + $this->register_test_block_type( 'test/no-client-nav' ); + + $parsed_block = array( + 'blockName' => 'test/no-client-nav', + 'attrs' => array(), + ); + $this->assertSame( $parsed_block, $this->interactivity->filter_render_block_data_client_navigation_support( $parsed_block ) ); + + $buffer = $this->get_page_html(); + + $this->assertSame( $buffer, $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ) ); + } + + /** + * Tests that the buffer is not modified when a block that is not registered + * has been rendered. + * + * @covers ::filter_render_block_data_client_navigation_support + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_with_an_unregistered_block() { + $this->process_router_region(); + + $parsed_block = array( + 'blockName' => 'test/unregistered', + 'attrs' => array(), + ); + $this->assertSame( $parsed_block, $this->interactivity->filter_render_block_data_client_navigation_support( $parsed_block ) ); + + $buffer = $this->get_page_html(); + + $this->assertSame( $buffer, $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ) ); + } + + /** + * Tests that blocks without a block name, i.e. freeform classic HTML, do not + * break the client-side navigation compatibility. + * + * @covers ::filter_render_block_data_client_navigation_support + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_block_without_name_does_not_break_compatibility() { + $this->process_router_region(); + + $parsed_block = array( + 'blockName' => null, + 'attrs' => array(), + ); + $this->assertSame( $parsed_block, $this->interactivity->filter_render_block_data_client_navigation_support( $parsed_block ) ); + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $this->get_page_html() ); + + $this->assertSame( 3, substr_count( $html, 'data-wp-router-managed' ) ); + } + + /** + * Tests that blocks supporting client-side navigation do not break the + * client-side navigation compatibility. + * + * @covers ::filter_render_block_data_client_navigation_support + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_block_with_client_navigation_support_does_not_break_compatibility() { + $this->process_router_region(); + $this->register_test_block_type( + 'test/client-nav', + array( + 'supports' => array( + 'interactivity' => array( 'clientNavigation' => true ), + ), + ) + ); + $this->register_test_block_type( + 'test/interactive', + array( + 'supports' => array( 'interactivity' => true ), + ) + ); + + $this->interactivity->filter_render_block_data_client_navigation_support( + array( + 'blockName' => 'test/client-nav', + 'attrs' => array(), + ) + ); + $this->interactivity->filter_render_block_data_client_navigation_support( + array( + 'blockName' => 'test/interactive', + 'attrs' => array(), + ) + ); + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $this->get_page_html() ); + + $this->assertSame( 3, substr_count( $html, 'data-wp-router-managed' ) ); + } + + /** + * Tests that the `rel` attribute is handled as a case-insensitive, + * space-separated list of tokens. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_link_rel_token_list_handling() { + $this->process_router_region(); + + $buffer = '' . + '' . + '' . + '' . + '' . + '' . + ''; + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ); + + $this->assertSame( + array( true, true, null, null, null ), + $this->get_managed_attributes( $html, array( 'tag_name' => 'link' ) ) + ); + } + + /** + * Tests that a non-string buffer is returned as is. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_non_string_buffer_is_returned_as_is() { + $this->process_router_region(); + + $this->assertNull( $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( null ) ); + $this->assertFalse( $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( false ) ); + $this->assertSame( array(), $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( array() ) ); + } + + /** + * Tests that style assets inside `noscript` elements are not marked. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_inside_noscript() { + $this->process_router_region(); + + $buffer = '' . + '' . + '' . + '' . + ''; + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ); + + $this->assertSame( array( true, null, true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + $this->assertSame( array( null ), $this->get_managed_attributes( $html, array( 'tag_name' => 'link' ) ) ); + } + + /** + * Tests that style assets inside `template` elements, including nested ones, + * are not marked. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_not_added_inside_template() { + $this->process_router_region(); + + $buffer = '' . + '' . + '' . + '' . + '' . + ''; + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ); + + $this->assertSame( array( true, null, true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + $this->assertSame( array( null ), $this->get_managed_attributes( $html, array( 'tag_name' => 'link' ) ) ); + } + + /** + * Tests that a self-closing `template` tag in foreign content, which has no + * closing tag, does not suppress the attribute for the rest of the document. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_self_closing_template_does_not_suppress_the_attribute() { + $this->process_router_region(); + + $buffer = '' . + '' . + '' . + '' . + '' . + ''; + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ); + + $this->assertSame( array( true, null, true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + } + + /** + * Tests that `style` elements inside inline SVG are marked. + * + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_attribute_is_added_to_svg_style() { + $this->process_router_region(); + + $buffer = ''; + + $html = $this->interactivity->filter_template_output_buffer_add_router_managed_attribute( $buffer ); + + $this->assertSame( array( true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + } + + /** + * Tests that the front-end filters are registered by `add_hooks()`. + * + * @covers ::add_hooks + */ + public function test_add_hooks_registers_front_end_filters() { + $this->interactivity->add_hooks(); + + $this->assertSame( + 20, + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + $this->assertNotFalse( + has_filter( 'render_block_data', array( $this->interactivity, 'filter_render_block_data_client_navigation_support' ) ) + ); + + $this->remove_hooks(); + } + + /** + * Tests that the front-end filters are not registered in admin requests. + * + * @covers ::add_hooks + */ + public function test_add_hooks_does_not_register_front_end_filters_in_admin() { + set_current_screen( 'edit-post' ); + $this->assertTrue( is_admin() ); + + $this->interactivity->add_hooks(); + + $this->assertFalse( + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + $this->assertFalse( + has_filter( 'render_block_data', array( $this->interactivity, 'filter_render_block_data_client_navigation_support' ) ) + ); + + $this->remove_hooks(); + set_current_screen( 'front' ); + } + + /** + * Tests that the registered filter is invoked when the template enhancement + * output buffer is filtered. + * + * @covers ::add_hooks + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_add_hooks_marks_style_assets_through_the_filter_chain() { + $this->interactivity->add_hooks(); + $this->process_router_region(); + + $buffer = $this->get_page_html(); + $html = apply_filters( 'wp_template_enhancement_output_buffer', $buffer, $buffer ); + + $this->assertSame( array( true, true ), $this->get_managed_attributes( $html, array( 'tag_name' => 'style' ) ) ); + $this->assertSame( array( true, null ), $this->get_managed_attributes( $html, array( 'tag_name' => 'link' ) ) ); + + $this->remove_hooks(); + } + + /** + * Tests the `has_router_region()` getter. + * + * @covers ::has_router_region + */ + public function test_has_router_region() { + $this->assertFalse( $this->interactivity->has_router_region() ); + + $this->process_router_region(); + + $this->assertTrue( $this->interactivity->has_router_region() ); + } +} diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php index a79702554dc64..8d097a63d6ff6 100644 --- a/tests/phpunit/tests/template.php +++ b/tests/phpunit/tests/template.php @@ -122,6 +122,9 @@ public function set_up() { remove_filter( 'should_load_block_assets_on_demand', '__return_true', 0 ); remove_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ); + // Remove the hook which is added by WP_Interactivity_API::add_hooks() during bootstrapping. + remove_filter( 'wp_template_enhancement_output_buffer', array( wp_interactivity(), 'filter_template_output_buffer_add_router_managed_attribute' ), 20 ); + global $wp_scripts, $wp_styles; $this->original_wp_scripts = $wp_scripts; $this->original_wp_styles = $wp_styles; From d46cadd73acca1d774abba7d7edf356cd16b5b7f Mon Sep 17 00:00:00 2001 From: David Date: Mon, 10 Aug 2026 12:38:38 +0200 Subject: [PATCH 2/2] Interactivity API: register the data-wp-router-managed machinery lazily. --- .../class-wp-interactivity-api.php | 279 ++++++++++++++- .../wpInteractivityAPI-wp-router-managed.php | 326 +++++++++++++++++- tests/phpunit/tests/template.php | 3 - 3 files changed, 580 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index dd838edd92bb2..344dc4209f50d 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -109,6 +109,29 @@ final class WP_Interactivity_API { */ private $all_blocks_support_client_navigation = true; + /** + * Flag that indicates whether the template enhancement output buffer started + * by core is active for the current request. + * + * It is set from the {@see 'wp_template_enhancement_output_buffer_started'} + * action. When it is `true`, the `data-wp-router-managed` attribute is added + * through the {@see 'wp_template_enhancement_output_buffer'} filter and no + * additional output buffer is needed. + * + * @since 7.2.0 + * @var bool + */ + private $template_output_buffer_started = false; + + /** + * Flag that indicates whether starting the fallback output buffer used to add + * the `data-wp-router-managed` attribute has already been considered. + * + * @since 7.2.0 + * @var bool + */ + private $router_managed_output_buffer_attempted = false; + /** * Set of script modules that can be loaded after client-side navigation. * @@ -413,8 +436,8 @@ public function register_script_modules() { * * @since 6.5.0 * @since 6.9.0 Adds support for client-side navigation in script modules. - * @since 7.2.0 Tracks the page-wide client-side navigation support and adds the `data-wp-router-managed` - * attribute to server-generated style assets. + * @since 7.2.0 Tracks the page-wide client-side navigation support and registers the hooks that decide how the + * `data-wp-router-managed` attribute is added to the server-generated style assets. */ public function add_hooks() { add_filter( 'script_module_data_@wordpress/interactivity', array( $this, 'filter_script_module_interactivity_data' ) ); @@ -429,19 +452,47 @@ public function add_hooks() { add_filter( 'render_block_data', array( $this, 'filter_render_block_data_client_navigation_support' ) ); /* - * The priority is set to 20 so this filter runs after the one added by - * `wp_hoist_late_printed_styles()`, which uses the default priority and - * can move style tags into the HEAD. That way, every style asset present - * in the final markup gets the attribute. - * - * Note that adding this filter is what makes core start the template - * enhancement output buffer, as documented in + * The `wp_template_enhancement_output_buffer` filter that adds the + * `data-wp-router-managed` attribute is not registered here on purpose. + * Merely having that filter registered when the template is included + * makes core start the template enhancement output buffer for every + * front-end request, which disables response streaming even on pages + * that never get the attribute. See * `wp_should_output_buffer_template_for_enhancement()`. + * + * Instead, the filter is added from + * `WP_Interactivity_API::data_wp_router_region_processor()`, i.e. only + * once a router region has actually been processed, and + * `WP_Interactivity_API::maybe_start_router_managed_output_buffer()` + * starts a dedicated output buffer when core's one is not running. */ - add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'filter_template_output_buffer_add_router_managed_attribute' ), 20 ); + add_action( 'wp_template_enhancement_output_buffer_started', array( $this, 'mark_template_output_buffer_started' ) ); + + /* + * The lowest possible priority guarantees that nothing hooked to + * `wp_head` prints a style asset before the fallback output buffer + * starts. Marking only part of the style assets of a page would be + * worse than not marking any of them, because the router would treat + * the unmarked ones as client-injected and preserve them across every + * client-side navigation. + */ + add_action( 'wp_head', array( $this, 'maybe_start_router_managed_output_buffer' ), PHP_INT_MIN ); } } + /** + * Records that the template enhancement output buffer started by core is + * active for the current request. + * + * This method is a {@see 'wp_template_enhancement_output_buffer_started'} + * action callback. + * + * @since 7.2.0 + */ + public function mark_template_output_buffer_started() { + $this->template_output_buffer_started = true; + } + /** * Tracks whether all the blocks rendered on the page support client-side * navigation. @@ -500,6 +551,12 @@ public function filter_render_block_data_client_navigation_support( $parsed_bloc * `noscript` and `template` elements, which are not rendered as part of the * document. * + * The filter is not registered upfront. It is added from + * {@see WP_Interactivity_API::data_wp_router_region_processor()} when a router + * region is processed, because registering it earlier would force core to + * start the template enhancement output buffer on every front-end request. See + * {@see WP_Interactivity_API::add_hooks()}. + * * The attribute lets the Interactivity API router tell server-rendered style * assets apart from the ones injected later by JavaScript, so the latter can * be preserved when the head is diffed during a client-side navigation. @@ -518,20 +575,175 @@ public function filter_template_output_buffer_add_router_managed_attribute( $buf return $buffer; } - if ( ! $this->has_processed_router_region || ! $this->all_blocks_support_client_navigation ) { + if ( ! $this->should_add_router_managed_attribute() ) { return $buffer; } + return $this->add_router_managed_attribute_to_style_assets( $buffer ); + } + + /** + * Starts an output buffer to add the `data-wp-router-managed` attribute to + * the style assets of the page when core's template enhancement output buffer + * is not running. + * + * This method is a {@see 'wp_head'} action callback registered with the lowest + * possible priority, so the buffer captures every style asset printed from + * that point on: the rest of the HEAD, the BODY and the footer. + * + * The buffer is only started when it is really needed. Core starts its own + * template enhancement output buffer whenever something requires it, and in + * that case the attribute is added through the + * {@see 'wp_template_enhancement_output_buffer'} filter instead. + * + * This fallback relies on the block template canvas, which renders the whole + * template, including the footer template parts, into a string before printing + * the doctype and firing `wp_head`. In that flow all the directives have + * already been processed by the time this method runs, so the conditions + * checked here are final and the markup printed before the buffer starts + * cannot contain style assets. + * + * In flows where blocks are rendered after `wp_head` instead, i.e. classic + * themes, block themes serving a PHP template, or a PHP template served + * through the {@see 'template_include'} filter by a plugin, no router region + * has been processed at this point, so this method intentionally does nothing. + * Those flows are covered by the + * {@see 'wp_template_enhancement_output_buffer'} filter as long as core's + * template enhancement output buffer is active, which classic themes enable by + * default since WordPress 6.9. When it is not active, e.g. a site opting out + * through the {@see 'wp_should_output_buffer_template_for_enhancement'} filter + * or a block theme serving a PHP template, the attribute is not emitted at + * all. That is a deliberate limitation: not marking any style asset is safe, + * whereas marking only some of them is not. + * + * @since 7.2.0 + */ + public function maybe_start_router_managed_output_buffer() { + if ( $this->router_managed_output_buffer_attempted || $this->template_output_buffer_started ) { + return; + } + + $this->router_managed_output_buffer_attempted = true; + + if ( ! $this->should_add_router_managed_attribute() ) { + return; + } + + /* + * The buffer is not closed explicitly. It is flushed through its callback + * by `wp_ob_end_flush_all()` on shutdown, like the one started by + * `wp_start_template_enhancement_output_buffer()`. + * + * As a side effect, third-party code that captures the output of `wp_head` + * with its own `ob_start()` and `ob_get_clean()` pair consumes this buffer + * instead of its own, because this one is started from within `wp_head`. + * The markup is not lost, but it is returned unmarked. This is an accepted + * tradeoff of covering the whole page from a single buffer. + */ + ob_start( + array( $this, 'finalize_router_managed_output_buffer' ), + /* + * An unlimited chunk size, so the entire output is passed to the + * callback at once and every style asset can be marked no matter where + * it was printed. + */ + 0, + /* + * The `PHP_OUTPUT_HANDLER_FLUSHABLE` flag is omitted so a `flush()` + * call cannot send a fragment of the page through the callback and + * leave the rest of the markup unprocessed. The buffer is still + * cleanable and removable, which is required because WordPress calls + * `wp_ob_end_flush_all()` before `wp_cache_close()`. Same rationale as + * `wp_start_template_enhancement_output_buffer()`. + */ + PHP_OUTPUT_HANDLER_STDFLAGS ^ PHP_OUTPUT_HANDLER_FLUSHABLE + ); + } + + /** + * Adds the `data-wp-router-managed` attribute to the style assets of the + * buffer started by {@see WP_Interactivity_API::maybe_start_router_managed_output_buffer()}. + * + * The conditions are checked again here because this callback runs when the + * buffer is finalized, i.e. at the very end of the request, and code running + * after `wp_head`, e.g. a `wp_footer` callback, may still have rendered a + * block without client-side navigation support or disabled client-side + * navigation. + * + * Unlike `wp_finalize_template_enhancement_output_buffer()`, this callback + * does not check the content type of the response. The buffer is only started + * from `wp_head`, so the response is an HTML document, and the HTML processor + * leaves markup without style assets untouched anyway. + * + * @since 7.2.0 + * + * @param string $output The output buffer. + * @param int $phase The output buffer phase bitmask. + * @return string The output buffer, with the attribute added to the style assets. + */ + public function finalize_router_managed_output_buffer( string $output, int $phase ): string { + // When the output is being cleaned, e.g. it is replaced with an error page, it must not be processed. + if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) !== 0 ) { + return $output; + } + + if ( ! $this->should_add_router_managed_attribute() ) { + return $output; + } + + try { + return $this->add_router_managed_attribute_to_style_assets( $output ); + } catch ( Throwable $e ) { + /* + * An exception thrown from an output buffer callback is fatal and + * discards the whole response, so the original output is returned + * unmodified instead. The page is served without the attribute, which + * only disables the optimization. + */ + return $output; + } + } + + /** + * Checks whether the style assets of the page can be marked with the + * `data-wp-router-managed` attribute. + * + * The attribute is only added when all the blocks rendered on the page support + * client-side navigation, at least one router region has been processed, and + * client-side navigation has not been disabled. + * + * @since 7.2.0 + * + * @return bool Whether the attribute can be added to the style assets. + */ + private function should_add_router_managed_attribute(): bool { + if ( ! $this->has_processed_router_region || ! $this->all_blocks_support_client_navigation ) { + return false; + } + /* * The configuration is read directly instead of through * `WP_Interactivity_API::config()` because that method would create an * empty `core/router` entry as a side effect of reading it. */ - if ( ! empty( $this->config_data['core/router']['clientNavigationDisabled'] ) ) { - return $buffer; - } + return empty( $this->config_data['core/router']['clientNavigationDisabled'] ); + } - $processor = new WP_HTML_Tag_Processor( $buffer ); + /** + * Adds the `data-wp-router-managed` attribute to every style asset found in + * the given markup. + * + * Every `