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..344dc4209f50d 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,43 @@ 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; + + /** + * 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. * @@ -399,11 +436,368 @@ 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 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' ) ); 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 `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_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. + * + * 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 hooks are registered by `add_hooks()`. + * + * The template enhancement output buffer filter must not be registered, + * because registering it is what makes core start the template enhancement + * output buffer on every front-end request. + * + * @covers ::add_hooks + */ + public function test_add_hooks_registers_front_end_hooks() { + $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->assertNotFalse( + has_filter( 'render_block_data', array( $this->interactivity, 'filter_render_block_data_client_navigation_support' ) ) + ); + $this->assertNotFalse( + has_action( 'wp_template_enhancement_output_buffer_started', array( $this->interactivity, 'mark_template_output_buffer_started' ) ) + ); + $this->assertSame( + PHP_INT_MIN, + has_action( 'wp_head', array( $this->interactivity, 'maybe_start_router_managed_output_buffer' ) ) + ); + + $this->remove_hooks(); + } + + /** + * Tests that the front-end hooks are not registered in admin requests. + * + * @covers ::add_hooks + */ + public function test_add_hooks_does_not_register_front_end_hooks_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->assertFalse( + has_action( 'wp_template_enhancement_output_buffer_started', array( $this->interactivity, 'mark_template_output_buffer_started' ) ) + ); + $this->assertFalse( + has_action( 'wp_head', array( $this->interactivity, 'maybe_start_router_managed_output_buffer' ) ) + ); + + $this->remove_hooks(); + set_current_screen( 'front' ); + } + + /** + * Tests that processing a router region registers the template enhancement + * output buffer filter. + * + * @covers ::data_wp_router_region_processor + */ + public function test_processing_a_router_region_registers_the_output_buffer_filter() { + $this->assertFalse( + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + + $this->process_router_region(); + + $this->assertSame( + 20, + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + + $this->remove_hooks(); + } + + /** + * Tests that the template enhancement output buffer filter is registered + * again when another router region is processed, so it survives other code + * removing the filters of the hook. + * + * @covers ::data_wp_router_region_processor + */ + public function test_processing_another_router_region_registers_the_output_buffer_filter_again() { + $this->process_router_region(); + + remove_all_filters( 'wp_template_enhancement_output_buffer' ); + $this->assertFalse( + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + + $this->process_router_region(); + + $this->assertSame( + 20, + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + + $this->remove_hooks(); + } + + /** + * Tests that processing markup without a router region does not register the + * template enhancement output buffer filter. + * + * @covers ::data_wp_router_region_processor + */ + public function test_processing_without_a_router_region_does_not_register_the_output_buffer_filter() { + $this->process_directives( '

x

' ); + + $this->assertFalse( + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + } + + /** + * Tests that processing a router region does not register the template + * enhancement output buffer filter in admin requests. + * + * @covers ::data_wp_router_region_processor + */ + public function test_processing_a_router_region_does_not_register_the_output_buffer_filter_in_admin() { + set_current_screen( 'edit-post' ); + $this->assertTrue( is_admin() ); + + $this->process_router_region(); + + $this->assertFalse( + has_filter( 'wp_template_enhancement_output_buffer', array( $this->interactivity, 'filter_template_output_buffer_add_router_managed_attribute' ) ) + ); + + $this->remove_hooks(); + set_current_screen( 'front' ); + } + + /** + * Tests that the registered filter is invoked when the template enhancement + * output buffer is filtered. + * + * @covers ::data_wp_router_region_processor + * @covers ::filter_template_output_buffer_add_router_managed_attribute + */ + public function test_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 that no output buffer is started when the conditions are not met. + * + * @covers ::maybe_start_router_managed_output_buffer + */ + public function test_output_buffer_is_not_started_without_router_region() { + $level = ob_get_level(); + + $this->interactivity->maybe_start_router_managed_output_buffer(); + + $this->assertSame( $level, ob_get_level() ); + } + + /** + * Tests that no output buffer is started when client-side navigation is + * disabled in the `core/router` config. + * + * @covers ::maybe_start_router_managed_output_buffer + */ + public function test_output_buffer_is_not_started_when_client_navigation_is_disabled() { + $this->process_router_region(); + $this->interactivity->config( 'core/router', array( 'clientNavigationDisabled' => true ) ); + + $level = ob_get_level(); + + $this->interactivity->maybe_start_router_managed_output_buffer(); + + $this->assertSame( $level, ob_get_level() ); + } + + /** + * Tests that an output buffer handled by the class is started when the + * conditions are met and core's template output buffer is not running. + * + * @covers ::maybe_start_router_managed_output_buffer + */ + public function test_output_buffer_is_started_when_conditions_are_met() { + $this->process_router_region(); + + $level = ob_get_level(); + + $this->interactivity->maybe_start_router_managed_output_buffer(); + + $this->assertSame( $level + 1, ob_get_level() ); + + $status = ob_get_status(); + ob_end_clean(); + + $this->assertSame( $level, ob_get_level() ); + $this->assertSame( 'WP_Interactivity_API::finalize_router_managed_output_buffer', $status['name'] ); + $this->assertSame( 0, $status['chunk_size'] ); + $this->assertSame( 0, $status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE ); + $this->assertNotSame( 0, $status['flags'] & PHP_OUTPUT_HANDLER_CLEANABLE ); + $this->assertNotSame( 0, $status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE ); + } + + /** + * Tests that the output buffer is only started once. + * + * @covers ::maybe_start_router_managed_output_buffer + */ + public function test_output_buffer_is_started_only_once() { + $this->process_router_region(); + + $level = ob_get_level(); + + $this->interactivity->maybe_start_router_managed_output_buffer(); + $this->interactivity->maybe_start_router_managed_output_buffer(); + + $this->assertSame( $level + 1, ob_get_level() ); + + ob_end_clean(); + } + + /** + * Tests that no output buffer is started when core's template enhancement + * output buffer is already running. + * + * @covers ::maybe_start_router_managed_output_buffer + * @covers ::mark_template_output_buffer_started + */ + public function test_output_buffer_is_not_started_when_template_output_buffer_started() { + $this->interactivity->add_hooks(); + $this->process_router_region(); + + /* + * The callback is invoked directly instead of through the action, because + * the other callbacks hooked to it, e.g. `wp_hoist_late_printed_styles()`, + * have side effects that are not relevant for this test. + */ + $this->interactivity->mark_template_output_buffer_started(); + + $level = ob_get_level(); + + $this->interactivity->maybe_start_router_managed_output_buffer(); + + $this->assertSame( $level, ob_get_level() ); + + $this->remove_hooks(); + } + + /** + * Tests that the output buffer callback marks the style assets when the + * buffer is finalized. + * + * @covers ::finalize_router_managed_output_buffer + */ + public function test_output_buffer_callback_marks_style_assets() { + $this->process_router_region(); + + $html = $this->interactivity->finalize_router_managed_output_buffer( $this->get_page_html(), PHP_OUTPUT_HANDLER_FINAL ); + + $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' ) ) ); + } + + /** + * Tests that the output buffer callback does not modify the output when the + * buffer is being cleaned. + * + * @covers ::finalize_router_managed_output_buffer + */ + public function test_output_buffer_callback_does_not_modify_cleaned_output() { + $this->process_router_region(); + + $buffer = $this->get_page_html(); + + $this->assertSame( + $buffer, + $this->interactivity->finalize_router_managed_output_buffer( $buffer, PHP_OUTPUT_HANDLER_CLEAN ) + ); + } + + /** + * Tests that the output buffer callback rechecks the conditions when the + * buffer is finalized, so markup rendered after `wp_head` is taken into + * account. + * + * @covers ::finalize_router_managed_output_buffer + */ + public function test_output_buffer_callback_rechecks_the_conditions() { + $this->process_router_region(); + + $level = ob_get_level(); + $this->interactivity->maybe_start_router_managed_output_buffer(); + $this->assertSame( $level + 1, ob_get_level() ); + ob_end_clean(); + + // Client-side navigation is disabled after the buffer has started. + $this->interactivity->config( 'core/router', array( 'clientNavigationDisabled' => true ) ); + + $buffer = $this->get_page_html(); + + $this->assertSame( + $buffer, + $this->interactivity->finalize_router_managed_output_buffer( $buffer, PHP_OUTPUT_HANDLER_FINAL ) + ); + } + + /** + * Tests that the output buffer started by the class marks the style assets of + * everything printed after it started. + * + * @covers ::maybe_start_router_managed_output_buffer + * @covers ::finalize_router_managed_output_buffer + */ + public function test_output_buffer_marks_the_captured_output() { + $this->process_router_region(); + + /* + * The buffer started by the class is nested inside a plain one, so it can + * be finalized with `ob_end_flush()`, which runs the callback, without + * sending anything to the actual output. + */ + ob_start(); + $level = ob_get_level(); + $this->interactivity->maybe_start_router_managed_output_buffer(); + + /* + * The buffer must have started before anything is printed. Otherwise, the + * clean up below would discard the buffers of the test runner. + */ + $this->assertSame( $level + 1, ob_get_level() ); + + echo $this->get_page_html(); + ob_end_flush(); + $html = ob_get_clean(); + + $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' ) ) ); + } + + /** + * 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() ); + } +}