From 74e89f53af2c09703f9b0d0e2d62daba9b59f4de Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 8 Sep 2026 08:19:19 +0000 Subject: [PATCH] Close a void element that set_tag_name renames to a non-void name `Element::set_tag_name` on an HTML void element (``, `
`, ``, `
`, ...) rewrote only the start tag. A void element has no end tag in the input, so nothing closed the new name: every sibling after `` renamed to `picture` re-parsed as a child of ``, and after a rename to `textarea` or `style` the rest of the document became its text. The start tag now carries an `emit_end_tag` flag. `set_tag_name` sets it when the element cannot have content, is in the HTML namespace, and the new name is not itself a void element name. The serializer then writes `` straight after the start tag, before any `after()` content, and drops a decorative `/>`. A rename to another void name, a rename of a self-closing element in foreign content (closed by its `/>` whatever the name), and `remove()`/`replace()` are unchanged. The element still reports `can_have_content() == false`, so `prepend`, `append` and `set_inner_content` stay no-ops and no end tag handlers run for the written tag. The void-name list moves from `selectors_vm::stack` to `Tag::is_void_html_element` so both callers share it. Nine existing tests used an `` rename as a match marker and now expect the end tag too. --- src/html/tag.rs | 23 +++ src/rewritable_units/element.rs | 187 +++++++++++++++++++++-- src/rewritable_units/tokens/start_tag.rs | 26 ++++ src/selectors_vm/stack.rs | 13 +- 4 files changed, 222 insertions(+), 27 deletions(-) diff --git a/src/html/tag.rs b/src/html/tag.rs index 20ad90db..6406785f 100644 --- a/src/html/tag.rs +++ b/src/html/tag.rs @@ -105,6 +105,29 @@ macro_rules! tag_is_one_of { }; } +impl Tag { + /// Whether `name` is one of the [void elements] of the HTML namespace + /// (plus the obsolete ones the tree builder still treats as void). + /// + /// [void elements]: https://html.spec.whatwg.org/multipage/syntax.html#void-elements + #[inline] + #[must_use] + pub(crate) fn is_void_html_element(name: &impl PartialEq) -> bool { + // NOTE: fast path for the most commonly used elements + if tag_is_one_of!(*name, [Div, A, Span, Li]) { + return false; + } + + tag_is_one_of!( + *name, + [ + Area, Base, Basefont, Bgsound, Br, Col, Embed, Hr, Img, Input, Keygen, Link, Meta, + Param, Source, Track, Wbr + ] + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rewritable_units/element.rs b/src/rewritable_units/element.rs index d9da98b8..743cb9f5 100644 --- a/src/rewritable_units/element.rs +++ b/src/rewritable_units/element.rs @@ -5,6 +5,7 @@ use super::{ }; use crate::HandlerResult; use crate::base::{BytesCow, SourceLocation}; +use crate::html::{LocalNameHash, Namespace, Tag}; use crate::rewriter::{HandlerTypes, LocalHandlerTypes}; use encoding_rs::Encoding; use std::any::Any; @@ -128,18 +129,31 @@ impl<'rewriter, 'input_token, H: HandlerTypes> Element<'rewriter, 'input_token, /// Sets the tag name of the element. /// - /// The new tag name must be in the same namespace, have the same content model, and be valid in its location. + /// The new tag name must be in the same namespace and be valid in its location. /// Otherwise change of the tag name may cause the resulting document to be parsed in an unexpected way, /// out of sync with this library. + /// + /// If the element is a [void element] (``, `
`, ...) and the new name is not, an end tag + /// `` is written right after the start tag, so that the renamed element stays empty instead of + /// swallowing the siblings that follow it. The element still [can't have content][Self::can_have_content]: + /// [`prepend`][Self::prepend], [`append`][Self::append] and [`set_inner_content`][Self::set_inner_content] + /// remain no-ops, and the written end tag doesn't run [end tag handlers][Self::end_tag_handlers]. + /// + /// [void element]: https://html.spec.whatwg.org/multipage/syntax.html#void-elements #[inline] pub fn set_tag_name(&mut self, name: &str) -> Result<(), TagNameError> { - let name = self.tag_name_bytes_from_str(name)?; + let name_bytes = self.tag_name_bytes_from_str(name)?; if self.can_have_content { - self.modified_end_tag_name = Some((*name).into()); + self.modified_end_tag_name = Some((*name_bytes).into()); + } else if self.start_tag.namespace() == Namespace::Html { + // A self-closing tag in foreign content is closed by its `/>` whatever its name is, + // but an HTML void element is closed by its name alone. + let emit_end_tag = !Tag::is_void_html_element(&LocalNameHash::from(name)); + self.start_tag.set_emit_end_tag(emit_end_tag); } - self.start_tag.set_name_raw(name); + self.start_tag.set_name_raw(name_bytes); Ok(()) } @@ -905,8 +919,8 @@ mod tests { ); assert_eq!( out, - " - --> + " + -->
" ); } @@ -922,8 +936,8 @@ mod tests { ); assert_eq!( out, - "

--> -

-->" + "

--> +

-->" ); } @@ -935,7 +949,10 @@ mod tests { "img", |el| el.set_tag_name("we-have-scripts").unwrap(), ); - assert_eq!(out, r#"