From f542501798d9356135a5efaff71bd5eb4c068185 Mon Sep 17 00:00:00 2001 From: Gabriel Silva Date: Sat, 31 Oct 2026 16:55:19 -0300 Subject: [PATCH 1/3] fix(parser): don't treat an HTML void name in an svg or math namespace as void --- .../src/parser/html/parser.rs | 38 ++++++++++++-- .../tests/html_parser_test.rs | 51 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index af5e02fa4..70e46483b 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,28 @@ 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`). + fn inherits_namespace(&self) -> bool { + let elements = self.container_stack.iter().rev().filter_map(|container| match container { + ContainerIndex::Element(idx) => Some(self.elements[*idx].name.as_str()), + ContainerIndex::Block(_) => None, + }); + for name in elements { + let (prefix, local_name) = split_ns_name(name); + 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 + } + /// 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 @@ -678,7 +700,11 @@ 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); + // Only an HTML element is void: in a namespace, `` is `:svg:area` and has children. + let in_namespace = has_ns_prefix + || get_html_tag_definition(&local_name).implicit_namespace_prefix.is_some() + || self.inherits_namespace(); + let is_void = !in_namespace && is_void_element(&tag_name); let element = HtmlElement { name: Ident::from_in(tag_name.clone(), &self.allocator), @@ -727,9 +753,13 @@ 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 + // Check if this is a void element - void elements don't have end tags. Only an HTML element + // is void: the closest open element passes its namespace down, as to the start tag. let tag_def = get_html_tag_definition(&local_name); - if tag_def.is_void { + let in_namespace = tag_name.starts_with(':') + || tag_def.implicit_namespace_prefix.is_some() + || self.inherits_namespace(); + if tag_def.is_void && !in_namespace { let err = self.make_error( start, format!("Void elements do not have end tags \"{}\"", local_name), diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index 2f1e64c69..565d14998 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -3138,3 +3138,54 @@ 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 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()] + ); + } +} From 95384184baa9881dc11df7c040cc39d3bfc26c56 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 2 Nov 2026 13:43:59 +0800 Subject: [PATCH 2/3] fix(parser): keep the element children of a void name in a namespace Follow-up to the namespace check for void names, from a review against Angular 22.2: - An open element with a void name is in a namespace (an HTML void element is never pushed), so Angular gives `:svg:area` the default tag definition: no child closes it and its parent's end tag can't skip it. `` failed with `Unexpected closing tag "area"`, and `` gave no error (Angular: `Unexpected closing tag ":svg:svg"`). - An i18n tag placeholder reads the parser's `is_void`: `` in svg gets `START_TAG__SVG_AREA` and `CLOSE_TAG__SVG_AREA`. - The open elements are walked only for a void name (`is_html_void`). Co-Authored-By: Claude Opus 5.5 --- .../src/parser/html/parser.rs | 32 +++++++++-------- .../src/transform/html_to_r3.rs | 3 +- .../tests/html_parser_test.rs | 35 +++++++++++++++++++ .../tests/integration_test.rs | 11 ++++++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index 70e46483b..eb8920bf0 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -320,6 +320,13 @@ impl<'a> HtmlParser<'a> { 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 @@ -397,8 +404,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) @@ -700,11 +710,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 }; - // Only an HTML element is void: in a namespace, `` is `:svg:area` and has children. - let in_namespace = has_ns_prefix - || get_html_tag_definition(&local_name).implicit_namespace_prefix.is_some() - || self.inherits_namespace(); - let is_void = !in_namespace && 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), @@ -753,13 +759,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. Only an HTML element - // is void: the closest open element passes its namespace down, as to the start tag. - let tag_def = get_html_tag_definition(&local_name); - let in_namespace = tag_name.starts_with(':') - || tag_def.implicit_namespace_prefix.is_some() - || self.inherits_namespace(); - if tag_def.is_void && !in_namespace { + // 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), @@ -1867,9 +1869,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 565d14998..bfebc90a5 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -3173,6 +3173,41 @@ mod foreign_void_elements { 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() + ] + ); + } + #[test] fn reads_html_void_elements_again_inside_foreign_object() { assert_eq!( 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#"abc"#); + 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�", From d96e72281c538766be6dcd9c814816fb832c6b98 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 2 Nov 2026 13:56:22 +0800 Subject: [PATCH 3/3] fix(parser): read a selectorless component's host tag for the namespace An open selectorless component is stored under its class name, so `` missed the `foreignObject` boundary and read `input` as `:svg:input`. Like Angular's `_getPrefix`, which reads a component's `tagName`, the walk now uses the component's prefix and host tag (`ng-component` without one). Co-Authored-By: Claude Opus 5.5 --- .../src/parser/html/parser.rs | 17 ++++++++++--- .../tests/html_parser_test.rs | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index eb8920bf0..cdd0e3683 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -301,14 +301,23 @@ impl<'a> HtmlParser<'a> { /// 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`). + /// 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].name.as_str()), + ContainerIndex::Element(idx) => Some(&self.elements[*idx]), ContainerIndex::Block(_) => None, }); - for name in elements { - let (prefix, local_name) = split_ns_name(name); + 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; diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index bfebc90a5..65663ae87 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -3208,6 +3208,31 @@ mod foreign_void_elements { ); } + /// 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!(