diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs
index af5e02fa4..cdd0e3683 100644
--- a/crates/oxc_angular_compiler/src/parser/html/parser.rs
+++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs
@@ -21,7 +21,7 @@ use crate::util::{ParseError, ParseLocation, ParseSourceFile, ParseSourceSpan};
use super::entities::decode_entities_in_string;
use super::lexer::{HtmlLexer, HtmlToken, HtmlTokenType};
-use super::tags::{get_html_tag_definition, is_void_element};
+use super::tags::{get_html_tag_definition, is_void_element, split_ns_name};
/// Result of parsing an HTML template.
pub struct HtmlParseResult<'a> {
@@ -298,6 +298,44 @@ impl<'a> HtmlParser<'a> {
idx
}
+ /// Whether a tag without a prefix inside the open elements takes a namespace from them, as
+ /// Angular's `_getElementFullName` names it: ` ` in `` is `:svg:area`. The closest
+ /// element (blocks are skipped) passes down its own prefix or implicit one, or the one it took
+ /// from its own parent, unless it stops inheritance (`foreignObject`). A selectorless component
+ /// passes down its host tag (``, `ng-component` without one), as
+ /// `_getPrefix` reads a component's `tagName`.
+ fn inherits_namespace(&self) -> bool {
+ let elements = self.container_stack.iter().rev().filter_map(|container| match container {
+ ContainerIndex::Element(idx) => Some(&self.elements[*idx]),
+ ContainerIndex::Block(_) => None,
+ });
+ for element in elements {
+ let (prefix, local_name) = if element.is_component {
+ (
+ element.component_prefix.as_ref().map(|prefix| prefix.as_str()),
+ element.component_tag_name.as_ref().map_or("ng-component", |tag| tag.as_str()),
+ )
+ } else {
+ split_ns_name(element.name.as_str())
+ };
+ let tag_def = get_html_tag_definition(local_name);
+ if tag_def.prevent_namespace_inheritance {
+ return false;
+ }
+ if prefix.is_some() || tag_def.implicit_namespace_prefix.is_some() {
+ return true;
+ }
+ }
+ false
+ }
+
+ /// Whether a void name is an HTML void element here: not in a namespace, by its own prefix or
+ /// one it inherits (` ` in `` is `:svg:area`, which has children and an end tag).
+ /// Void names have no implicit namespace.
+ fn is_html_void(&self, local_name: &str, has_prefix: bool) -> bool {
+ get_html_tag_definition(local_name).is_void && !has_prefix && !self.inherits_namespace()
+ }
+
/// Pops a block from the container stack and returns it as an HtmlNode.
fn pop_block_container(&mut self, end_span: Option) -> Option> {
// Find the topmost block in the stack
@@ -375,8 +413,11 @@ impl<'a> HtmlParser<'a> {
// be implicitly closed silently. Anything else means this end tag is
// jumping past a still-open element, which is what Angular's reference
// parser flags via `unexpectedCloseTagDetected`.
+ // An open element with a void name is in a namespace (an HTML void element
+ // is never pushed), so Angular gives it the default definition.
let elem_name = self.elements[idx].name.as_str().to_lowercase();
- if !get_html_tag_definition(&elem_name).closed_by_parent {
+ let tag_def = get_html_tag_definition(&elem_name);
+ if !tag_def.closed_by_parent || tag_def.is_void {
unexpected_close_detected = true;
}
// Get the element without end_span (implicitly closed)
@@ -678,7 +719,7 @@ impl<'a> HtmlParser<'a> {
// For self-closing elements, end_span equals start_span
// For void elements or regular elements, end_span is set later or stays None
let end_span = if is_self_closing { Some(start_span) } else { None };
- let is_void = is_void_element(&tag_name);
+ let is_void = self.is_html_void(&local_name, has_ns_prefix);
let element = HtmlElement {
name: Ident::from_in(tag_name.clone(), &self.allocator),
@@ -727,9 +768,9 @@ impl<'a> HtmlParser<'a> {
};
let end_span = self.make_span(start, end);
- // Check if this is a void element - void elements don't have end tags
- let tag_def = get_html_tag_definition(&local_name);
- if tag_def.is_void {
+ // Void elements don't have end tags. The open elements pass their namespace down, as to
+ // the start tag.
+ if self.is_html_void(&local_name, tag_name.starts_with(':')) {
let err = self.make_error(
start,
format!("Void elements do not have end tags \"{}\"", local_name),
@@ -1837,9 +1878,11 @@ impl<'a> HtmlParser<'a> {
/// Checks if the current element should be auto-closed when a new element is opened.
/// Uses the tag definitions from tags.rs to match Angular's behavior exactly.
+/// An open element with a void name is in a namespace (an HTML void element is never pushed):
+/// `:svg:area` has the default definition, which no child closes.
fn should_auto_close(current_tag: &str, new_tag: &str) -> bool {
let tag_def = get_html_tag_definition(current_tag);
- tag_def.is_closed_by_child(new_tag)
+ !tag_def.is_void && tag_def.is_closed_by_child(new_tag)
}
#[cfg(test)]
diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
index 007c8e063..213000a34 100644
--- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
+++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
@@ -4618,7 +4618,8 @@ impl<'a> HtmlToR3Transform<'a> {
use indexmap::IndexMap;
let tag_name = element.name.as_str();
- let is_void = crate::parser::html::is_void_element(tag_name);
+ // The parser's value: `:svg:area` is no void element (i18n_parser.ts reads the full name).
+ let is_void = element.is_void;
// Collect attributes for placeholder name generation
let mut attrs: IndexMap = element
diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs
index 2f1e64c69..65663ae87 100644
--- a/crates/oxc_angular_compiler/tests/html_parser_test.rs
+++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs
@@ -3138,3 +3138,114 @@ mod visitor_tests {
assert!(result.iter().filter(|n| n.node_type() == Some("Attribute")).count() == 2);
}
}
+
+// ============================================================================
+// Void and self-closing elements in a foreign namespace
+// ============================================================================
+//
+// Angular names an element without a prefix in its parent's namespace (`_getElementFullName`), so
+// ` ` inside `` is `:svg:area`, which is no void element. `foreignObject` stops the
+// inheritance. Each case matches ngc 22.2.
+
+mod foreign_void_elements {
+ use super::*;
+
+ #[test]
+ fn takes_an_end_tag_for_an_html_void_name_in_a_foreign_namespace() {
+ for html in [
+ " ",
+ " ",
+ " ",
+ " ",
+ "@if (x) { } ",
+ " ",
+ " ",
+ ] {
+ assert_eq!(parse_errors(html), Vec::::new(), "{html}");
+ }
+ }
+
+ #[test]
+ fn keeps_the_children_of_an_html_void_name_in_a_foreign_namespace() {
+ let (nodes, errors) = parse_with_errors(" t ");
+ assert_eq!(errors, Vec::::new());
+ assert!(nodes.contains(&element("area", 1)), "{nodes:?}");
+ assert!(nodes.contains(&text("t", 2)), "{nodes:?}");
+ }
+
+ #[test]
+ fn keeps_the_element_children_of_an_html_void_name_in_a_foreign_namespace() {
+ for (html, expected) in [
+ (
+ " ",
+ vec![element("svg", 0), element("area", 1), element("g", 2)],
+ ),
+ (
+ "x ",
+ vec![element("math", 0), element("br", 1), element("mi", 2), text("x", 3)],
+ ),
+ (
+ " ",
+ vec![element("svg", 0), element("br", 1), element("br", 2)],
+ ),
+ ] {
+ let (nodes, errors) = parse_with_errors(html);
+ assert_eq!(errors, Vec::::new(), "{html}");
+ assert_eq!(nodes, expected, "{html}");
+ }
+ }
+
+ #[test]
+ fn reports_an_html_void_name_left_open_in_a_foreign_namespace() {
+ // `:svg:img` needs an end tag: ` ` skips an open element, as upstream.
+ let (nodes, errors) = parse_with_errors(" ");
+ assert_eq!(nodes, vec![element("svg", 0), element("img", 1), element("circle", 2)]);
+ assert_eq!(
+ errors,
+ vec![
+ "Unexpected closing tag \"svg\". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags".to_string()
+ ]
+ );
+ }
+
+ /// The errors of a template in selectorless mode.
+ fn selectorless_errors(html: &str) -> Vec {
+ let allocator = Allocator::default();
+ let result = HtmlParser::with_selectorless(&allocator, html, "TestComp").parse();
+ result.errors.iter().map(|e| e.msg.clone()).collect()
+ }
+
+ #[test]
+ fn reads_the_host_tag_of_a_selectorless_component() {
+ // A component passes down its host tag's namespace, `ng-component` without a tag.
+ for html in [
+ " ",
+ "
",
+ " ",
+ " ",
+ " ",
+ ] {
+ assert_eq!(selectorless_errors(html), Vec::::new(), "{html}");
+ }
+ assert_eq!(
+ selectorless_errors(" "),
+ vec!["Void elements do not have end tags \"area\"".to_string()]
+ );
+ }
+
+ #[test]
+ fn reads_html_void_elements_again_inside_foreign_object() {
+ assert_eq!(
+ parse_errors(" "),
+ vec!["Void elements do not have end tags \"area\"".to_string()]
+ );
+ }
+
+ #[test]
+ fn still_reports_an_end_tag_for_an_html_void_element() {
+ assert_eq!(
+ parse_errors(" "),
+ vec!["Void elements do not have end tags \"area\"".to_string()]
+ );
+ }
+}
diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs
index 87fda0f68..eaa9abb13 100644
--- a/crates/oxc_angular_compiler/tests/integration_test.rs
+++ b/crates/oxc_angular_compiler/tests/integration_test.rs
@@ -6373,6 +6373,17 @@ fn test_i18n_structural_directive_element_placeholders() {
assert!(!js.contains("i18nPostprocess"), "No post-processing expected:\n{js}");
}
+/// An HTML void name in svg is no void element, so it gets a start and a close placeholder.
+/// Angular 22.2: `a{$START_TAG__SVG_AREA}b{$CLOSE_TAG__SVG_AREA}c`.
+#[test]
+fn test_i18n_void_name_in_svg_has_a_close_placeholder() {
+ let js = compile_i18n_component(r#"a bc "#);
+ assert_contains(
+ &js,
+ "a${\"\u{FFFD}#3\u{FFFD}\"}:START_TAG__SVG_AREA:b${\"\u{FFFD}/#3\u{FFFD}\"}:CLOSE_TAG__SVG_AREA:c`",
+ );
+}
+
/// `@if`/`@else` inside an i18n block: each block gets its own start/close placeholder values.
/// Angular 22.1.5:
/// { "closeBlockElse": "�/*3:2�", "closeBlockIf": "�/*2:1�", "closeTagSpan": "�/#1:1�",