diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php
index 0cd1f0fc45e07..4e9dfb5b928ff 100644
--- a/src/wp-includes/html-api/class-wp-html-open-elements.php
+++ b/src/wp-includes/html-api/class-wp-html-open-elements.php
@@ -588,7 +588,7 @@ public function remove_node( WP_HTML_Token $token ): bool {
$position_from_start = $this->count() - $position_from_end - 1;
array_splice( $this->stack, $position_from_start, 1 );
- $this->after_element_pop( $item );
+ $this->after_element_pop( $item, 0 === $position_from_end );
return true;
}
@@ -731,9 +731,10 @@ public function after_element_push( WP_HTML_Token $item ): void {
*
* @since 6.4.0
*
- * @param WP_HTML_Token $item Element that was removed from the stack of open elements.
+ * @param WP_HTML_Token $item Element that was removed from the stack of open elements.
+ * @param bool $invoke_pop_handler Whether to call the pop handler.
*/
- public function after_element_pop( WP_HTML_Token $item ): void {
+ public function after_element_pop( WP_HTML_Token $item, bool $invoke_pop_handler = true ): void {
/*
* When adding support for new elements, expand this switch to trap
* cases where the precalculated value needs to change.
@@ -767,7 +768,7 @@ public function after_element_pop( WP_HTML_Token $item ): void {
break;
}
- if ( null !== $this->pop_handler ) {
+ if ( $invoke_pop_handler && null !== $this->pop_handler ) {
call_user_func( $this->pop_handler, $item );
}
}
diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php
index 35d91fad3129c..f7c9fe285fdd9 100644
--- a/src/wp-includes/html-api/class-wp-html-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-processor.php
@@ -251,6 +251,15 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor {
*/
private $current_element = null;
+ /**
+ * Elements removed from the stack of open elements without a normal pop event.
+ *
+ * @since 7.1.0
+ *
+ * @var array[]
+ */
+ private $non_lifo_breadcrumb_removals = array();
+
/**
* Context node if created as a fragment parser.
*
@@ -814,6 +823,10 @@ private function next_visitable_token(): bool {
* tokens works in the meantime and isn't obviously wrong.
*/
if ( empty( $this->element_queue ) ) {
+ if ( $this->queue_virtual_closer_after_non_lifo_removal() ) {
+ return $this->next_visitable_token();
+ }
+
if ( $this->step() ) {
return $this->next_visitable_token();
}
@@ -823,6 +836,10 @@ private function next_visitable_token(): bool {
}
}
+ if ( $this->queue_virtual_closer_after_non_lifo_removal() ) {
+ return $this->next_visitable_token();
+ }
+
// Process the next event on the queue.
$this->current_element = array_shift( $this->element_queue );
if ( ! isset( $this->current_element ) ) {
@@ -860,6 +877,68 @@ private function next_visitable_token(): bool {
return true;
}
+ /**
+ * Queues a virtual closer for a removed node once its subtree closes.
+ *
+ * Non-LIFO removals from the stack of open elements do not emit a normal
+ * pop event because those events blindly pop the current breadcrumb. The
+ * removed node remains an ancestor of the currently open subtree, but must
+ * be reported as a virtual closer before visiting the next token after
+ * that subtree closes.
+ *
+ * @since 7.1.0
+ *
+ * @return bool Whether a virtual closer was queued.
+ */
+ private function queue_virtual_closer_after_non_lifo_removal(): bool {
+ if ( empty( $this->non_lifo_breadcrumb_removals ) ) {
+ return false;
+ }
+
+ $removed_node = end( $this->non_lifo_breadcrumb_removals );
+ $removed_token = $removed_node['token'];
+ $breadcrumb_depth = $removed_node['breadcrumb_depth'];
+
+ if (
+ count( $this->breadcrumbs ) !== $breadcrumb_depth ||
+ empty( $this->breadcrumbs ) ||
+ end( $this->breadcrumbs ) !== $removed_token->node_name
+ ) {
+ return false;
+ }
+
+ // At EOF, normal stack pops may be queued and processed after the stack is empty.
+ $adjusted_current_node = $this->get_adjusted_current_node();
+
+ if ( isset( $adjusted_current_node ) && end( $this->breadcrumbs ) === $adjusted_current_node->node_name ) {
+ return false;
+ }
+
+ /*
+ * The depth and node-name checks above cannot distinguish the removed
+ * element from a same-named element at the same depth; identity is
+ * recovered here. If a queued POP closes a different element with the
+ * same name, that element owns the current breadcrumb and the virtual
+ * closer must wait for it.
+ */
+ $next_event = reset( $this->element_queue );
+ if (
+ false !== $next_event &&
+ WP_HTML_Stack_Event::POP === $next_event->operation &&
+ $next_event->token !== $removed_token &&
+ $next_event->token->node_name === $removed_token->node_name
+ ) {
+ return false;
+ }
+
+ array_pop( $this->non_lifo_breadcrumb_removals );
+ array_unshift(
+ $this->element_queue,
+ new WP_HTML_Stack_Event( $removed_token, WP_HTML_Stack_Event::POP, 'virtual' )
+ );
+ return true;
+ }
+
/**
* Indicates if the current tag token is a tag closer.
*
@@ -2848,7 +2927,28 @@ private function step_in_body(): bool {
case 'A':
$this->run_adoption_agency_algorithm();
$this->state->active_formatting_elements->remove_node( $item );
- $this->state->stack_of_open_elements->remove_node( $item );
+ $is_current_node = $item === $this->state->stack_of_open_elements->current_node();
+
+ /*
+ * The removed node's breadcrumb sits at its position in the
+ * stack of open elements: one crumb for each open element at
+ * or below it. Fragment parsers carry an extra crumb for the
+ * context node, which never appears on the stack.
+ */
+ $stack_position = 0;
+ foreach ( $this->state->stack_of_open_elements->walk_down() as $node ) {
+ ++$stack_position;
+ if ( $node === $item ) {
+ break;
+ }
+ }
+
+ if ( $this->state->stack_of_open_elements->remove_node( $item ) && ! $is_current_node ) {
+ $this->non_lifo_breadcrumb_removals[] = array(
+ 'token' => $item,
+ 'breadcrumb_depth' => isset( $this->context_node ) ? $stack_position + 1 : $stack_position,
+ );
+ }
break 2;
}
}
@@ -5675,6 +5775,7 @@ public function seek( $bookmark_name ): bool {
$this->state->current_token = null;
$this->current_element = null;
$this->element_queue = array();
+ $this->non_lifo_breadcrumb_removals = array();
/*
* The absence of a context node indicates a full parse.
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php
index 911fa8b910b37..2c18a10a94712 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php
@@ -418,6 +418,459 @@ public function test_remains_stable_when_editing_attributes() {
);
}
+ /**
+ * Ensures that HTML elements inside MathML text integration points retain
+ * the full path to their MathML parent.
+ *
+ * @ticket 61576
+ *
+ * @covers WP_HTML_Processor::get_breadcrumbs
+ * @covers WP_HTML_Processor::get_namespace
+ */
+ public function test_reports_nested_anchor_breadcrumbs_inside_mathml_text_integration_point() {
+ $processor = WP_HTML_Processor::create_fragment( '