diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs index d4659ab19..3d29f4e22 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -106,6 +106,13 @@ impl ImportValueResolver for CrossFileResolver { fn resolve(&self, module: &str, name: &str) -> Option { self.inner.resolve_export_value(module, name, &self.from_file) } + + fn exports( + &self, + module: &str, + ) -> Option)>> { + self.inner.export_values_of(module, &self.from_file) + } } impl CrossFileAnalyzer { @@ -341,6 +348,94 @@ impl Inner { self.export_value_at(&resolved, import_name, 0) } + /// Every value `import_source` exports, for `{ ...ns }` with + /// `import * as ns` (ngtsc's `ResolvedModule.getExports()`): each name + /// with the value it resolves to, or `None` for one that can't be read + /// statically — ngtsc keeps those as `DynamicValue`s in the object rather + /// than dropping the key. `None` for the whole map when the module can't + /// be resolved, like [`Self::resolve_export_value`]. + fn export_values_of( + &self, + import_source: &str, + from_file: &Path, + ) -> Option)>> { + let parent = from_file.parent()?; + let resolved = self.resolver.resolve(parent, import_source).ok()?; + let resolved_path = resolved.full_path(); + if resolved_path.components().any(|c| c.as_os_str() == "node_modules") { + return None; + } + let resolved = resolved_path.to_string_lossy().to_string(); + self.value_dependencies.borrow_mut().insert(resolved.clone()); + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { + return None; // Circular import + } + self.analyze_file(&resolved); + } + let mut names = Vec::new(); + self.export_names_at(&resolved, &mut FxHashSet::default(), &mut names); + Some( + names + .into_iter() + .map(|name| { + let value = self.export_value_at(&resolved, &name, 0); + (name, value) + }) + .collect(), + ) + } + + /// The names `file_path` exports, direct ones first — the first wins when + /// an `export *` re-exports a name the file also has (`getExportsOfModule` + /// answers one symbol per name) — then each star export's. `visited` + /// guards circular `export *` chains. + fn export_names_at( + &self, + file_path: &str, + visited: &mut FxHashSet, + names: &mut Vec, + ) { + if !visited.insert(file_path.to_string()) { + return; + } + let Some(exports) = self.cache.borrow().get(file_path).cloned() else { + return; + }; + // `export *` sources, stored under their `*:` keys (see + // `find_in_star_exports_inner`). `export * as ns` carries the same + // `(source, "*")` marker but exports `ns` as one name — it isn't + // enumerated flat. + let star_sources: Vec = exports + .iter() + .filter(|(key, _)| key.starts_with("*:")) + .filter_map(|(_, info)| { + info.re_export_source.as_ref().map(|(source, _)| source.clone()) + }) + .collect(); + for name in exports.keys() { + if !name.starts_with("*:") && !names.contains(name) { + names.push(name.clone()); + } + } + let Some(file_parent) = Path::new(file_path).parent() else { + return; + }; + for source in star_sources { + let Some(resolved) = self.resolve_module_spec(file_parent, &source) else { + continue; + }; + self.value_dependencies.borrow_mut().insert(resolved.clone()); + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { + continue; + } + self.analyze_file(&resolved); + } + self.export_names_at(&resolved, visited, names); + } + } + /// The value `export_name` binds to in `file_path`, direct or through /// re-exports. Bounded by [`MAX_EXPORT_CHAIN`] for circular chains. fn export_value_at( diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index c797893ff..a1d4e0b99 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -18,8 +18,9 @@ use super::metadata::{ }; use super::transform::ImportMap; use crate::directive::{ - StringConsts, extract_host_bindings_in, extract_host_listeners_in, extract_input_metadata_in, - extract_output_metadata_in, merge_by_class_property, parse_decorator_io, + HostKey, StringConsts, extract_host_bindings_in, extract_host_listeners_in, + extract_input_metadata_in, extract_output_metadata_in, merge_by_class_property, + parse_decorator_io, }; use crate::output::oxc_converter::convert_oxc_expression; use crate::util::is_metadata_property; @@ -106,13 +107,31 @@ pub fn extract_component_metadata<'a>( crate::directive::extract_string_value(allocator, &prop.value, consts); } "styles" => { - if let Some(styles) = extract_string_array(allocator, &prop.value, consts) { - metadata.styles = styles; - } else if let Some(style) = - crate::directive::extract_string_value(allocator, &prop.value, consts) - { - // Single style string (legacy support) - metadata.styles.push(style); + // ngtsc's `reflectObjectLiteral` drops computed keys and + // folds duplicate `styles` entries with `Map.set` — the + // last one is the only one `parseDirectiveStyles` sees. + if prop.computed { + continue; + } + // Anything that isn't styles is an error `styles_error` reports. + match crate::directive::evaluate_styles(&prop.value, consts) { + Ok(styles) => { + metadata.styles = Vec::from_iter_in( + styles.iter().map(|style| Ident::from(allocator.alloc_str(style))), + &allocator, + ); + // A later `styles:` overwrites an earlier failed + // one: `Map.set` makes the last entry the only + // one upstream reads. + metadata.styles_resolved = true; + } + Err(_) => { + // Failed evaluation is reported as a diagnostic; + // here it marks the value unknown so readers do + // not mistake the empty array for "no styles". + metadata.styles.clear(); + metadata.styles_resolved = false; + } } } "styleUrls" | "styleUrl" => { @@ -137,7 +156,13 @@ pub fn extract_component_metadata<'a>( metadata.change_detection = Some(extract_change_detection(&prop.value)); } "host" => { - metadata.host = extract_host_metadata(allocator, &prop.value, consts); + // ngtsc's `reflectObjectLiteral` drops computed keys, and + // `host_metadata_error` doesn't see this property either — + // an invalid one must not be applied here where it would + // be dropped silently. + if !prop.computed { + metadata.host = extract_host_metadata(allocator, &prop.value, consts); + } } "imports" => { // For standalone components, we need: @@ -216,6 +241,13 @@ pub fn extract_component_metadata<'a>( // Extract host bindings and listeners from @HostBinding/@HostListener decorators on class members // These are merged with any host metadata from the @Component({ host: {} }) property + // + // Divergence from ngtsc (pre-existing merge structure): upstream assigns + // member entries into `bindings.properties[name]` / `bindings.listeners` + // [eventName], overwriting same-named `host:` entries so one binding per + // key remains. Here they are appended instead, so a key declared in both + // emits two bindings — both listeners fire, and both property ops emit + // (the member's, applied last, still wins like upstream). let host_bindings = extract_host_bindings_in(allocator, class, Some(consts)); let host_listeners = extract_host_listeners_in(allocator, class, Some(consts)); @@ -480,71 +512,38 @@ fn extract_change_detection(expr: &Expression<'_>) -> ChangeDetectionStrategy { } } -/// Extract host metadata from a host object expression. +/// Extract host metadata from a host expression. /// /// Reference: packages/compiler/src/render3/view/compiler.ts:560-604 fn extract_host_metadata<'a>( allocator: &'a Allocator, - expr: &Expression<'a>, + expr: &'a Expression<'a>, consts: &StringConsts<'a>, ) -> Option> { - let Expression::ObjectExpression(obj) = expr else { - return None; - }; - - let mut host = HostMetadata { - properties: Vec::new_in(&allocator), - attributes: Vec::new_in(&allocator), - listeners: Vec::new_in(&allocator), - class_attr: None, - style_attr: None, - }; - - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop - && is_metadata_property(prop) - { - let Some(key_name) = get_property_key_name(&prop.key, consts) else { - continue; - }; - let Some(value) = - crate::directive::extract_string_value(allocator, &prop.value, consts) - else { - continue; - }; - - let key_str = key_name.as_str(); - - if key_str.starts_with('[') && key_str.ends_with(']') { - // Property binding: [class.active] - host.properties.push((key_name, value)); - } else if key_str.starts_with('(') && key_str.ends_with(')') { - // Event listener: (click) - host.listeners.push((key_name, value)); - } else { - // Check for special attributes (class and style) - // Reference: compiler.ts:567-588 - match key_str { - "class" => { - host.class_attr = Some(value); - } - "style" => { - host.style_attr = Some(value); - } - _ => { - // Regular static attribute - host.attributes.push((key_name, value)); - } - } - } + // An unusable host is reported by `decorator_io_errors`. + let entries = + crate::directive::evaluate_host_metadata(allocator, expr, "Component", consts).ok()?; + + let mut host = HostMetadata::new(allocator); + for (key, value) in entries { + match HostKey::of(key.as_str()) { + HostKey::Property => host.properties.push((key, value)), + HostKey::Listener => host.listeners.push((key, value)), + HostKey::Class => host.class_attr = Some(value), + HostKey::Style => host.style_attr = Some(value), + HostKey::Attribute => host.attributes.push((key, value)), } } - Some(host) } /// Extract host directives from a hostDirectives array expression. /// +/// Divergence from ngtsc's `extractHostDirectives`: upstream evaluates the +/// expression with the partial evaluator (a const or spread array works, a +/// non-array is an error). Here only a literal `ArrayExpression` is read and +/// anything else yields no host directives, with no diagnostic. +/// /// Handles the following patterns: /// - Simple identifier: `hostDirectives: [TooltipDirective]` /// - Object with directive: `hostDirectives: [{ directive: ColorDirective }]` @@ -732,6 +731,9 @@ fn extract_forward_ref_directive_name<'a>(arg: Option<&Argument<'a>>) -> Option< /// - Mapping string: `["color: bgColor"]` - public name mapped to internal name /// /// Returns Vec of (publicName, internalName) pairs. +/// +/// Divergence: ngtsc evaluates the array with the partial evaluator, so a +/// const or a spread works; here only a literal `ArrayExpression` is read. fn extract_io_mappings<'a>( allocator: &'a Allocator, expr: &Expression<'a>, diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index deed550c0..e1c412075 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -241,6 +241,12 @@ pub struct ComponentMetadata<'a> { /// Inline styles array. pub styles: Vec<'a, Ident<'a>>, + /// `false` when the decorator had a `styles` field the static evaluator + /// could not resolve (e.g. an import `resolveImportedValues` does not + /// cover): `styles` is then empty and must not be read as "no styles". + /// `true` when `styles` was absent or fully evaluated. + pub styles_resolved: bool, + /// URLs to external stylesheet files. pub style_urls: Vec<'a, Ident<'a>>, @@ -659,6 +665,7 @@ impl<'a> ComponentMetadata<'a> { template: None, template_url: None, styles: Vec::new_in(&allocator), + styles_resolved: true, style_urls: Vec::new_in(&allocator), standalone: implicit_standalone, encapsulation: ViewEncapsulation::default(), diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 934427735..276cb6bb9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -44,7 +44,7 @@ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, decorator_io_errors, extract_class_queries, extract_directive_metadata, - find_directive_decorator, generate_directive_definitions, param_decorator_errors, + find_directive_decorator, generate_directive_definitions, param_decorator_errors, styles_error, }; use crate::dts; use crate::injectable::{ @@ -418,6 +418,21 @@ pub struct HmrTemplateCompileOutput { /// constant references. Without this, the template may reference indices that don't /// exist in the old component definition's consts array. pub consts_js: Option, + + /// The number of element, text and container slots the template creates. + pub decls: u32, + + /// The number of binding slots the template uses. + pub vars: u32, + + /// The `ngContentSelectors` array as JavaScript code, when the template projects + /// content. It refers to a constant declared in `declarations_js`. + pub ng_content_selectors_js: Option, + + /// Non-fatal diagnostics the pipeline reported while compiling (warnings, + /// advice). Error-severity diagnostics are returned as `Err` instead, since + /// the emitted code cannot be trusted. + pub diagnostics: Vec, } /// Compiled component information. @@ -3367,7 +3382,14 @@ pub fn transform_angular_file( // throws earlier — malformed decorator args, a missing // selector — for shapes oxc doesn't diagnose yet, so this // can be the first error where ngtsc would report another. - result.diagnostics.extend(param_decorator_errors(class, &string_consts)); + let param_errors = param_decorator_errors(class, &string_consts); + result.diagnostics.extend(param_errors.iter().cloned()); + if param_errors.is_empty() { + // `styles` resolves in the component handler, after + // `extractDirectiveMetadata` — including the constructor + // checks above — has passed. + result.diagnostics.extend(styles_error(class, &string_consts)); + } } if let Some(mut metadata) = extract_component_metadata( @@ -3447,11 +3469,26 @@ pub fn transform_angular_file( shared_pool_index, &mut file_namespace_registry, &import_map, + resolved_resources, ) { Ok(compilation_result) => { // Update the shared pool index for the next component shared_pool_index = compilation_result.next_pool_index; + // Stylesheet ``s in the template are watched + // files like decorator `styleUrls`, and a missing + // one is the same non-fatal diagnostic. + for style_url in &compilation_result.template_style_urls { + result.dependencies.push(style_url.clone()); + } + for style_url in &compilation_result.missing_link_urls { + result.diagnostics.push(OxcDiagnostic::error(format!( + "Component '{}': style URL '{}' could not be resolved \ + (COMPONENT_RESOURCE_NOT_FOUND)", + class_name, style_url + ))); + } + let component_id = format!("{}@{}", path, class_name); let component_decorator = @@ -3725,6 +3762,7 @@ pub fn transform_angular_file( class, &string_consts, source, + &parser_ret.program.comments, dts_core_namespace.as_str(), ); result.dts_declarations.push(dts::generate_component_dts( @@ -3875,6 +3913,7 @@ pub fn transform_angular_file( class, &string_consts, source, + &parser_ret.program.comments, dts_core_namespace.as_str(), ); result.dts_declarations.push(dts::generate_directive_dts( @@ -3902,9 +3941,18 @@ pub fn transform_angular_file( compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); + // Constants pooled by the host bindings are declared before the class. + let mut decls_before_class = String::new(); + for stmt in &definitions.statements { + if !decls_before_class.is_empty() { + decls_before_class.push('\n'); + } + decls_before_class.push_str(&emitter.emit_statement(stmt)); + } + class_definitions.insert( class_name, - (property_assignments, String::new(), decls_after_class), + (property_assignments, decls_before_class, decls_after_class), ); } else if let Some(mut pipe_metadata) = extract_pipe_metadata_in( allocator, @@ -4589,6 +4637,16 @@ struct FullCompilationResult { /// async metadata callback, and emitting them would leave dead /// `import(...)` calls in the output. has_defer_block: bool, + + /// Resolvable-URL stylesheet ``s the template's preparser collected + /// (upstream `_extractTemplateStyleUrls`). The caller records them as + /// watch dependencies like decorator `styleUrls`. + template_style_urls: Vec, + + /// Template link URLs missing from `resolved_resources` — non-fatal like + /// a missing decorator `styleUrl`: the caller surfaces + /// COMPONENT_RESOURCE_NOT_FOUND in the result diagnostics. + missing_link_urls: Vec, } /// Compile a component template and generate ɵcmp/ɵfac definitions. @@ -4654,6 +4712,10 @@ fn compile_component_partial<'a>( next_pool_index: pool_starting_index, ng_content_selectors: Vec::new(), has_defer_block, + // Partial mode never parses the template; the linker collects link + // URLs at consumer build time. + template_style_urls: Vec::new(), + missing_link_urls: Vec::new(), } } @@ -4669,6 +4731,7 @@ fn compile_component_full<'a>( pool_starting_index: u32, namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, + resolved_resources: Option<&ResolvedResources>, ) -> Result> { use oxc_allocator::FromIn; @@ -4743,6 +4806,27 @@ fn compile_component_full<'a>( let ng_content_selectors: Vec = r3_result.ng_content_selectors.iter().map(|s| s.to_string()).collect(); + // Resolve stylesheet ``s the preparser collected (`_extractTemplateStyleUrls`): + // they compile to styles like decorator `styleUrls` (handler.ts resolves both through + // `resourceLoader` and the link element is removed from the template). Upstream puts + // external styles before inline ones; the file's own ordering keeps URL contents after + // the decorator's inline styles, and the links land ahead of the `

a

", + &[ + "decls:2,vars:0,", + r#"i0.ɵɵelementStart(0,"p");i0.ɵɵtext(1,"a");i0.ɵɵelementEnd();"#, + r#"styles:["p[_ngcontent-%COMP%]{}"]"#, + ], + ); + assert_compiles_to( + "{{ t }}", + &[ + "decls:4,vars:2,", + r#"i0.ɵɵelementStart(0,"TEXTAREA");i0.ɵɵtext(1);i0.ɵɵelementEnd();i0.ɵɵelementStart(2,"Title");"#, + ], + ); +} + +/// The preparser lowercases the name before it looks for `ng-content`, so any spelling +/// projects content. `ng-container` and `ng-template` are matched as written, so in +/// capitals they are ordinary elements. +#[test] +fn angular_element_names_written_in_capitals() { + assert_compiles_to( + r#""#, + &[ + "ngContentSelectors:_c1,decls:2,vars:0,", + "i0.ɵɵprojectionDef(_c0);i0.ɵɵprojection(0);i0.ɵɵprojection(1,1);", + ], + ); + assert_compiles_to( + "ab", + &[ + "decls:4,vars:0,", + r#"i0.ɵɵelementStart(0,"NG-CONTAINER");i0.ɵɵtext(1,"a");i0.ɵɵelementEnd();i0.ɵɵelementStart(2,"Ng-Template");"#, + ], + ); +} + +/// Names inside `` are legitimately mixed case, and a capitalised `` still +/// puts its children in the svg namespace. +#[test] +fn namespaced_elements() { + assert_compiles_to( + r#"
x
"#, + &[ + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelementStart(0,"svg");i0.ɵɵelement(1,"Circle",0);i0.ɵɵelementStart(2,"foreignObject");i0.ɵɵnamespaceHTML();i0.ɵɵelementStart(3,"DIV");"#, + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelement(5,"linearGradient");i0.ɵɵelementEnd();i0.ɵɵelement(6,"Circle");"#, + ], + ); + assert_compiles_to( + "x", + &[ + r#"i0.ɵɵnamespaceSVG();i0.ɵɵelementStart(0,"SVG");i0.ɵɵelement(1,"rect");i0.ɵɵelementEnd();"#, + r#"i0.ɵɵnamespaceMathML();i0.ɵɵelementStart(2,"MATH")(3,"mi");"#, + ], + ); +} + +#[test] +fn lowercase_elements_are_unchanged() { + assert_compiles_to( + r#"hia"#, + &[ + "decls:5,vars:1,", + r#"i0.ɵɵelementStart(0,"view")(1,"text");i0.ɵɵtext(2,"hi");i0.ɵɵelementEnd()();i0.ɵɵelementStart(3,"my-widget",0);"#, + ], + ); +} + +/// Only a selectorless parse produces component nodes. The pipeline cannot compile +/// them, and says so rather than emitting a template without them. +#[test] +fn selectorless_component_node_is_reported() { + let template = "

a

b"; + let allocator = Allocator::default(); + let html = HtmlParser::with_selectorless(&allocator, template, "test.html").parse(); + assert!(html.errors.is_empty(), "{:?}", html.errors); + let r3 = HtmlToR3Transform::new(&allocator, template, HtmlTransformOptions::default()) + .transform(&html.nodes); + assert!(r3.errors.is_empty(), "{:?}", r3.errors); + let mut job = ingest_component(&allocator, Ident::from("X"), r3.nodes); + let result = compile_template(&mut job); + + let messages: Vec<&str> = job.diagnostics.iter().map(|d| &*d.message).collect(); + assert_eq!( + messages, + [ + "Selectorless component cannot be compiled: selectorless components are not supported." + ] + ); + // The rest of the template is still compiled. + let emitted = JsEmitter::new().emit_statements(&result.template_fn.statements); + assert!(emitted.contains(r#"i0.ɵɵelementStart(0,"p")"#), "{emitted}"); +} + +/// A template stylesheet `` compiles its resolvable URL into the +/// component's styles and disappears from the emitted template, like the +/// preparser classifying it STYLESHEET and the transform returning `null`. +/// Attribute names are matched case-insensitively and in any order, because +/// `preparseElement` lowercases them. +#[test] +fn capitalised_stylesheet_links() { + use std::collections::HashMap; + + use oxc_angular_compiler::ResolvedResources; + + let cases = [ + // Uppercase tag and attributes, `HREF` before `REL`. + "", + // Mixed case tag, lowercase attributes. + "", + ]; + for template in cases { + let source = format!( + "import {{ Component }} from '@angular/core';\n\ + @Component({{ selector: 'x', template: `{template}` }})\n\ + export class X {{}}" + ); + let allocator = Allocator::default(); + let mut styles = HashMap::new(); + styles.insert("./theme.css".to_string(), vec![".link-style{color:red}".to_string()]); + let resources = ResolvedResources { styles, ..Default::default() }; + let result = transform_angular_file( + &allocator, + "/x/a.ts", + &source, + Some(&TransformOptions::default()), + Some(&resources), + ); + assert!(result.diagnostics.is_empty(), "`{template}`: {:?}", result.diagnostics); + // The collected stylesheet joins the component's styles… + assert!( + result.code.contains("link-style"), + "`{template}` should compile the linked stylesheet:\n{}", + result.code + ); + // …and the link element is dropped from the template. + let compact: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + !compact.contains("LINK") || !compact.contains("ɵɵelement"), + "`{template}` should not emit a link element:\n{compact}" + ); + } +} + +/// A resolvable stylesheet `` missing from the provided resources is a +/// COMPONENT_RESOURCE_NOT_FOUND diagnostic like a missing `styleUrl`. +#[test] +fn missing_stylesheet_link_is_a_resource_error() { + use std::collections::HashMap; + + use oxc_angular_compiler::ResolvedResources; + + let source = "import { Component } from '@angular/core';\n\ + @Component({ selector: 'x', template: `` })\n\ + export class X {}"; + let allocator = Allocator::default(); + let resources = ResolvedResources { styles: HashMap::new(), ..Default::default() }; + let result = transform_angular_file( + &allocator, + "/x/a.ts", + source, + Some(&TransformOptions::default()), + Some(&resources), + ); + let messages: Vec = result.diagnostics.iter().map(|d| d.message.to_string()).collect(); + assert!( + messages.iter().any(|m| m.contains("style URL './theme.css' could not be resolved")), + "{messages:?}" + ); +} + +/// Inside `ngNonBindable` a stylesheet `` is still dropped +/// (`NonBindableVisitor` checks `type === STYLESHEET`), but its URL is not +/// recorded — and an unresolvable or missing href does not save it, because +/// `isStyleUrlResolvable` is not consulted there. Outside `ngNonBindable` an +/// unresolvable stylesheet link stays an ordinary element. +#[test] +fn stylesheet_links_inside_ng_non_bindable() { + let non_bindable_div = r#"i0.ɵɵelementStart(0,"div");i0.ɵɵdisableBindings();i0.ɵɵtext(1,"a");i0.ɵɵenableBindings();"#; + assert_compiles_to( + r#"
a
"#, + &[non_bindable_div], + ); + assert_compiles_to( + r#"
a
"#, + &[non_bindable_div], + ); + assert_compiles_to(r#"
a
"#, &[non_bindable_div]); + // Outside ngNonBindable an unresolvable stylesheet link stays an element, + // and so does a link without `rel="stylesheet"`. + assert_compiles_to( + r#""#, + &[r#"i0.ɵɵelement(0,"link",0)"#], + ); + assert_compiles_to( + r#""#, + &[r#"i0.ɵɵelement(0,"link",0)"#], + ); +} + +/// `isNgContent` reads the namespace-stripped local name lowercased, so both +/// a namespaced `` and a capitalised `` project +/// content, and its `select` attribute is matched case-insensitively. +#[test] +fn namespaced_and_capitalised_ng_content() { + assert_compiles_to( + "", + &[ + "ngContentSelectors:_c1,", + "i0.ɵɵprojectionDef(_c0);i0.ɵɵprojection(0);i0.ɵɵprojection(1,1);", + ], + ); +} + +/// `isNgTemplate` reads the namespace-stripped local name as written, so a +/// namespaced `` is a template while a capitalised +/// `` is an ordinary element. +#[test] +fn namespaced_ng_template_vs_capitalised() { + assert_compiles_to("

x

", &["decls:"]); + assert_compiles_to( + "a", + &[r#"i0.ɵɵelementStart(0,"NG-TEMPLATE");i0.ɵɵtext(1,"a")"#], + ); +} diff --git a/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs new file mode 100644 index 000000000..07bb4d59c --- /dev/null +++ b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs @@ -0,0 +1,538 @@ +//! `host` in `@Component` / `@Directive` metadata is evaluated statically, as ngtsc's +//! `evaluateHostExpressionBindings` does: a spread of an object, or a host that is itself +//! a constant, compiles to the same bindings as the entries written out in a literal. +//! A host that cannot be used is an error on the host expression, never a silent drop. +//! +//! Every expectation is what `@angular/compiler-cli` 22.2.1 produces for the same source: +//! for the equivalence cases its definitions are identical for both forms, and the error +//! messages are its diagnostics (with the chained line joined by a space, as elsewhere). + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; +use oxc_diagnostics::OxcDiagnostic; + +const MEMBERS: &str = "go() {} other() {} t = ''; on = true; w = '';"; + +fn source(decorator: &str, preamble: &str, host: &str) -> String { + let rest = + if decorator == "Component" { "selector: 'x', template: ''," } else { "selector: '[x]'," }; + format!( + "import {{ {decorator} }} from '@angular/core';\n{preamble}\n\ + @{decorator}({{ {rest} host: {host} }})\nexport class X {{ {MEMBERS} }}\n" + ) +} + +fn compile(source: &str) -> (String, Vec) { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "/x/a.ts", + source, + Some(&TransformOptions::default()), + None, + ); + (result.code, result.diagnostics) +} + +/// The whitespace-free host part of the compiled definition: `hostAttrs`, `hostVars` +/// and `hostBindings`, or nothing when the class has no host metadata. +fn host_definition(decorator: &str, preamble: &str, host: &str) -> String { + let (code, diagnostics) = compile(&source(decorator, preamble, host)); + assert!(diagnostics.is_empty(), "unexpected diagnostics for `{host}`: {diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let start = compact.find("({type:X,").expect("definition"); + // The definition ends where the class metadata call, which repeats `host`, starts. + let definition = &compact[start..]; + let definition = &definition[..definition.find("(()=>").unwrap_or(definition.len())]; + let Some(from) = definition.find(",host") else { return String::new() }; + let to = definition[from..].find("decls:").map_or(definition.len(), |i| from + i); + definition[from..to].to_string() +} + +/// `host` compiles to what `literal` compiles to. +#[track_caller] +fn assert_same_as_literal(decorator: &str, preamble: &str, host: &str, literal: &str) { + let expected = host_definition(decorator, "", literal); + assert_eq!( + host_definition(decorator, preamble, host), + expected, + "`host: {host}` should compile like `host: {literal}`" + ); +} + +/// Compiling `host` reports exactly `message`, on the host expression. +#[track_caller] +fn assert_error(preamble: &str, host: &str, message: &str) { + for decorator in ["Component", "Directive"] { + let source = source(decorator, preamble, host); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `{host}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, message, "for `host: {host}`"); + let labels: Vec<&str> = diagnostic + .labels + .iter() + .map(|l| &source[l.offset() as usize..(l.offset() + l.len()) as usize]) + .collect(); + assert_eq!(labels, [host], "label for `host: {host}`"); + } +} + +const NOT_AN_OBJECT: &str = "Decorator host metadata must be an object"; +const UNPARSEABLE_VALUE: &str = + "Decorator host metadata must be a string -> string object, but found unparseable value"; + +// The two reported inputs. + +#[test] +fn spread_inside_the_host_object() { + let (code, diagnostics) = compile( + "import { Component } from '@angular/core'; +const SHARED = { '(press)': 'go()' }; +@Component({ + selector: 'x', + template: '', + host: { ...SHARED, accessibilityRole: 'button' }, +}) +export class X { go() {} } +", + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(r#"hostAttrs:["accessibilityRole","button"]"#), "{compact}"); + assert!( + compact.contains( + r#"i0.ɵɵlistener("press",functionX_press_HostBindingHandler(){returnctx.go();});"# + ), + "{compact}" + ); +} + +#[test] +fn host_that_is_a_constant() { + let (code, diagnostics) = compile( + "import { Component } from '@angular/core'; +const HOST = { '(press)': 'go()' }; +@Component({ selector: 'x', template: '', host: HOST }) +export class X { go() {} } +", + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + r#"i0.ɵɵlistener("press",functionX_press_HostBindingHandler(){returnctx.go();});"# + ), + "{compact}" + ); +} + +// Spreads. + +#[test] +fn spread_before_and_after_other_entries() { + let shared = "const SHARED = { '(press)': 'go()' };"; + let literal = "{ '(press)': 'go()', accessibilityRole: 'button' }"; + for decorator in ["Component", "Directive"] { + assert_same_as_literal( + decorator, + shared, + "{ ...SHARED, accessibilityRole: 'button' }", + literal, + ); + assert_same_as_literal( + decorator, + shared, + "{ accessibilityRole: 'button', ...SHARED }", + "{ accessibilityRole: 'button', '(press)': 'go()' }", + ); + } +} + +/// The later entry wins, and keeps the position of the first one with that key. +#[test] +fn later_entry_overrides_an_earlier_one() { + assert_same_as_literal( + "Component", + "const SHARED = { role: 'a', '(press)': 'go()' };", + "{ ...SHARED, role: 'b', '(press)': 'other()' }", + "{ role: 'b', '(press)': 'other()' }", + ); + assert_same_as_literal( + "Component", + "const SHARED = { role: 'a' };", + "{ role: 'b', ...SHARED }", + "{ role: 'a' }", + ); + assert_same_as_literal( + "Component", + "const SHARED = { role: 'x', id: 'i' };", + "{ ...SHARED, title: 't', role: 'y' }", + "{ role: 'y', id: 'i', title: 't' }", + ); +} + +#[test] +fn two_spreads_and_a_spread_of_a_spread() { + assert_same_as_literal( + "Component", + "const A = { '(press)': 'go()' }; const B = { '[title]': 't', role: 'x' };", + "{ ...A, ...B }", + "{ '(press)': 'go()', '[title]': 't', role: 'x' }", + ); + assert_same_as_literal( + "Component", + "const A = { '(press)': 'go()' }; const B = { ...A, role: 'x' };", + "{ ...B, id: 'y' }", + "{ '(press)': 'go()', role: 'x', id: 'y' }", + ); +} + +// A host that is a constant. + +#[test] +fn constant_host_with_and_without_as_const() { + for decorator in ["Component", "Directive"] { + assert_same_as_literal( + decorator, + "const HOST = { '(press)': 'go()', role: 'x' };", + "HOST", + "{ '(press)': 'go()', role: 'x' }", + ); + assert_same_as_literal( + decorator, + "const HOST = { '(press)': 'go()', role: 'x' } as const;", + "HOST", + "{ '(press)': 'go()', role: 'x' }", + ); + } +} + +/// ngtsc evaluates these too; none of them is an error. +#[test] +fn other_statically_resolvable_hosts() { + let literal = "{ '(press)': 'go()' }"; + for (preamble, host) in [ + ("let HOST = { '(press)': 'go()' };", "HOST"), + ("var HOST = { '(press)': 'go()' };", "HOST"), + ("const HOST: Record = { '(press)': 'go()' };", "HOST"), + ("const HOST = { '(press)': 'go()' };", "HOST as any"), + ("const HOST = { '(press)': 'go()' };", "(HOST)"), + ("const HOST = { '(press)': 'go()' };", "HOST!"), + ("const C = { h: { '(press)': 'go()' } };", "C.h"), + ("class C { static HOST = { '(press)': 'go()' }; }", "C.HOST"), + ("function make() { return { '(press)': 'go()' }; }", "make()"), + ("function make() { return { '(press)': 'go()' }; }", "{ ...make() }"), + ("let SHARED = { '(press)': 'go()' };", "{ ...SHARED }"), + ("const DEV = true; const A = { '(press)': 'go()' };", "{ ...(DEV ? A : {}) }"), + ] { + assert_same_as_literal("Component", preamble, host, literal); + } +} + +// Values. + +#[test] +fn entry_values_that_are_string_constants() { + assert_same_as_literal( + "Component", + "const HANDLER = 'go()'; const ROLE = 'button';", + "{ '(press)': HANDLER, role: ROLE }", + "{ '(press)': 'go()', role: 'button' }", + ); + assert_same_as_literal( + "Component", + "const KEY = '(press)';", + "{ [KEY]: 'go()' }", + "{ '(press)': 'go()' }", + ); + assert_same_as_literal( + "Component", + "const N = 'x'; enum E { A = 'a' } const DEV = true; const role = 'r';", + "{ id: `a-${N}`, title: E.A, lang: DEV ? 'a' : 'b', role }", + "{ id: 'a-x', title: 'a', lang: 'a', role: 'r' }", + ); +} + +// Every kind of key, through a spread and through a constant. + +#[test] +fn every_kind_of_key() { + let literal = "{ '(click)': 'go()', '[title]': 't', '[class.x]': 'on', \ + '[style.width]': 'w', '[attr.aria-label]': 't', role: 'button', \ + class: 'a b', style: 'color: red' }"; + let preamble = format!("const ALL = {literal};"); + for decorator in ["Component", "Directive"] { + let expected = host_definition(decorator, "", literal); + for needle in [ + r#"hostAttrs:["role","button",1,"a","b",2,"color","red"]"#, + "hostVars:6", + r#"i0.ɵɵlistener("click""#, + r#"i0.ɵɵdomProperty("title",ctx.t)"#, + r#"i0.ɵɵattribute("aria-label",ctx.t)"#, + r#"i0.ɵɵstyleProp("width",ctx.w)"#, + r#"i0.ɵɵclassProp("x",ctx.on)"#, + ] { + assert!(expected.contains(needle), "missing `{needle}` in:\n{expected}"); + } + assert_same_as_literal(decorator, &preamble, "{ ...ALL }", literal); + assert_same_as_literal(decorator, &preamble, "ALL", literal); + } +} + +/// A plain literal host compiles exactly as it did before hosts were evaluated. +#[test] +fn literal_host_is_unchanged() { + assert_eq!( + host_definition("Component", "", "{ '(press)': 'go()', accessibilityRole: 'button' }"), + r#",hostAttrs:["accessibilityRole","button"],hostBindings:functionX_HostBindings(rf,ctx){if((rf&1)){i0.ɵɵlistener("press",functionX_press_HostBindingHandler(){returnctx.go();});}},"# + ); + assert_eq!(host_definition("Component", "", "{}"), ""); + assert_eq!(host_definition("Component", "const HOST = {};", "HOST"), ""); + assert_same_as_literal( + "Component", + "", + "{ role: 'a', id: 'i', role: 'b' }", + "{ role: 'b', id: 'i' }", + ); +} + +// Errors: what ngtsc reports, on the host expression. + +#[test] +fn host_that_is_not_an_object() { + for (preamble, host, suffix) in [ + ("", "['a']", "Value is of type '[string]'."), + ("", "'a'", "Value is of type 'string'."), + ("", "null", "Value is of type 'null'."), + ("", "undefined", "Value is of type 'undefined'."), + ("declare function make(): any;", "make()", "Value could not be determined statically."), + ( + "const HOST = Object.freeze({ '(press)': 'go()' });", + "HOST", + "Value could not be determined statically.", + ), + ( + "const HOST = { '(press)': 'go()' } satisfies Record;", + "HOST", + "Value could not be determined statically.", + ), + ] { + assert_error(preamble, host, &format!("{NOT_AN_OBJECT} {suffix}")); + } +} + +/// A spread or member ngtsc cannot evaluate makes the whole object unresolvable. +#[test] +fn host_object_that_cannot_be_evaluated() { + for (preamble, host) in [ + ("declare function make(): any;", "{ ...make(), role: 'x' }"), + ("", "{ ...NOPE, role: 'x' }"), + ("const S = 'abc';", "{ ...S }"), + ("const S = ['a'];", "{ ...S }"), + ("const K = 1;", "{ [K]: 'a' }"), + ("", "{ role() { return 'a' } }"), + ("", "{ get role() { return 'a' } }"), + ] { + assert_error( + preamble, + host, + &format!("{NOT_AN_OBJECT} Value could not be determined statically."), + ); + } +} + +#[test] +fn host_value_that_is_not_a_string() { + for (preamble, host, suffix) in [ + ("", "{ tabindex: 0 }", "Value is of type 'number'."), + ("", "{ '[tabindex]': 0 }", "Value is of type 'number'."), + ("", "{ '(click)': 0 }", "Value is of type 'number'."), + ("", "{ class: 1 }", "Value is of type 'number'."), + ("const S = { hidden: true };", "{ ...S }", "Value is of type 'boolean'."), + ("", "{ role: { a: 1 } }", "Value is of type '{ a: number }'."), + ("", "{ role: null }", "Value is of type 'null'."), + ("const U = undefined;", "{ role: U }", "Value is of type 'undefined'."), + ("declare const s: string;", "{ role: s }", "Value is a reference to 's'."), + ("declare const s: string;", "{ '[title]': s }", "Value is a reference to 's'."), + // The first unusable value is the one reported. + ("", "{ a: 1, b: true }", "Value is of type 'number'."), + ] { + assert_error(preamble, host, &format!("{UNPARSEABLE_VALUE} {suffix}")); + } +} + +/// A value ngtsc cannot evaluate at all is an error for everything but a plain attribute. +#[test] +fn dynamic_value_of_a_binding() { + let make = "declare function make(): string;"; + assert_error(make, "{ '[title]': make() }", "Property binding must be string"); + assert_error(make, "{ '(click)': make() }", "Event binding must be string"); + assert_error(make, "{ class: make() }", "Class binding must be string"); + assert_error(make, "{ style: make() }", "Style binding must be string"); +} + +/// ngtsc emits a plain attribute's dynamic value as an expression in `hostAttrs` +/// (`["role", make()]`). That is not supported here, and is reported instead of dropped. +#[test] +fn dynamic_value_of_a_plain_attribute() { + let message = |key: &str| { + format!( + "Decorator host metadata must be a string -> string object, but the value of \ + '{key}' could not be determined statically. OXC does not support dynamic \ + host attribute values." + ) + }; + assert_error("declare function make(): string;", "{ role: make() }", &message("role")); + assert_error("", "{ role: NOPE }", &message("role")); + assert_error( + "declare function make(): string; const S = { title: make() };", + "{ ...S, id: 'a' }", + &message("title"), + ); +} + +/// ngtsc reads another file to evaluate an import. One file at a time, that is an error +/// that says so. +#[test] +fn host_that_depends_on_an_import() { + let imported = |subject: &str| { + format!( + "{subject} depends on 'SHARED', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files." + ) + }; + let preamble = "import { SHARED } from './other';"; + for (decorator, subject) in [("Component", "@Component.host"), ("Directive", "@Directive.host")] + { + for host in + ["SHARED", "{ ...SHARED, role: 'x' }", "{ role: SHARED }", "{ '(click)': SHARED }"] + { + let source = source(decorator, preamble, host); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `{host}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, imported(subject), "for `host: {host}`"); + } + } +} + +/// ngtsc builds `hostMetadata` by assigning each entry to a plain object, so +/// `__proto__` invokes the setter and never becomes a key — in a literal, in +/// a constant, or coming in through a spread. +#[test] +fn proto_key_is_dropped() { + for decorator in ["Component", "Directive"] { + for (preamble, host) in [ + ("", "{ __proto__: 'x', role: 'r' }"), + ("", "{ '__proto__': 'x', role: 'r' }"), + ("const P = { __proto__: 'x' };", "{ ...P, role: 'r' }"), + ("const P = { __proto__: 'x' };", "{ ...P }"), + ] { + let (code, diagnostics) = compile(&source(decorator, preamble, host)); + assert!(diagnostics.is_empty(), "diagnostics for `{host}`: {diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + // Only the compiled host counts — `ɵsetClassMetadata` echoes the + // decorator arguments verbatim, like ngtsc does. + assert!( + !compact.contains(r#"hostAttrs:["__proto__""#) + && !compact.contains(r#"hostAttrs:[1,"__proto__""#), + "`__proto__` reached hostAttrs for `{host}`:\n{compact}" + ); + } + // The rest of the object is still read. + let (code, _) = compile(&source(decorator, "", "{ __proto__: 'x', role: 'r' }")); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(r#""role","r""#), "{compact}"); + } +} + +/// A computed `host` key isn't metadata: ngtsc's `reflectObjectLiteral` +/// returns null for a ComputedPropertyName, and the error check doesn't see +/// the property either — applying it here would silently drop an invalid +/// host, or apply a valid one ngtsc ignores. +#[test] +fn computed_host_key_is_not_metadata() { + for decorator in ["Component", "Directive"] { + for (preamble, property) in [ + // A valid host ngtsc never reads — and an invalid one that must + // not error, since the property is ignored like upstream. + ("", "['host']: { role: 'r' }"), + ("", "['host']: { role: 1 }"), + ("const HOST = 'host';", "[HOST]: { role: 'r' }"), + ] { + let rest = if decorator == "Component" { + "selector: 'x', template: ''," + } else { + "selector: '[x]'," + }; + let src = format!( + "import {{ {decorator} }} from '@angular/core';\n{preamble}\n\ + @{decorator}({{ {rest} {property} }})\nexport class X {{ {MEMBERS} }}\n" + ); + let (code, diagnostics) = compile(&src); + assert!( + diagnostics.is_empty(), + "unexpected diagnostics for `{property}`: {diagnostics:?}" + ); + assert!(!code.contains("hostAttrs"), "no host output for `{property}`"); + } + } +} + +/// `verifyHostBindings` forbids binding a host property or attribute to an +/// event; the errors are ngtsc's HOST_BINDING_PARSE_ERROR messages +/// (compiler.ts's `validateNoEventBindings`). +#[test] +fn binding_to_an_event_is_disallowed() { + for (host, message) in [ + ( + "{ '[onclick]': 't' }", + "Binding to event property 'onclick' is disallowed for security reasons, \ + please use (click)=...\nIf 'onclick' is a directive input, make sure the \ + directive is imported by the current module.", + ), + ( + "{ '[attr.onload]': 't' }", + "Binding to event attribute 'onload' is disallowed for security reasons, \ + please use (load)=...", + ), + ( + "{ '[onClick]': 't' }", + "Binding to event property 'onClick' is disallowed for security reasons, \ + please use (Click)=...\nIf 'onClick' is a directive input, make sure the \ + directive is imported by the current module.", + ), + ] { + assert_error("", host, message); + } + // Only property keys are checked: '(onx)' is a listener, 'onx' an attribute. + let literal = "{ '(onx)': 'go()', onx: 'a' }"; + assert_same_as_literal("Component", "const H = { '(onx)': 'go()', onx: 'a' };", "H", literal); +} + +/// A method or accessor called `host` in the decorator object is a class +/// member, not metadata — ngtsc's `reflectObjectLiteral` skips it, so it must +/// not produce the "must be an object" error (regression: `config_property` +/// matched it by name). +#[test] +fn host_that_is_a_method_or_getter_is_not_metadata() { + for decorator in ["Component", "Directive"] { + for host in ["host() { return {}; }", "get host() { return {}; }", "set host(v) {}"] { + let rest = if decorator == "Component" { + "selector: 'x', template: ''," + } else { + "selector: '[x]'," + }; + let src = format!( + "import {{ {decorator} }} from '@angular/core';\n\ + @{decorator}({{ {rest} {host} }})\nexport class X {{ {MEMBERS} }}\n" + ); + let (code, diagnostics) = compile(&src); + assert!(diagnostics.is_empty(), "unexpected diagnostics for `{host}`: {diagnostics:?}"); + assert!(!code.contains("hostAttrs"), "no host output for `{host}`"); + } + } +} diff --git a/crates/oxc_angular_compiler/tests/html_lexer_test.rs b/crates/oxc_angular_compiler/tests/html_lexer_test.rs index ad777c3ab..8a1202c16 100644 --- a/crates/oxc_angular_compiler/tests/html_lexer_test.rs +++ b/crates/oxc_angular_compiler/tests/html_lexer_test.rs @@ -3020,3 +3020,69 @@ mod regular_text { ); } } + +// ============================================================================ +// Raw-text Closing Tag Tests +// ============================================================================ + +mod raw_text_close { + use super::*; + + /// Upstream `_consumeRawTextWithTagClose` matches the close with + /// `_attemptStrCaseInsensitive(parts[1])`, the local name — `` + /// closes ``, and the close token keeps the opener's + /// `[prefix, name]` parts (`_endToken(openToken.parts)`). + #[test] + fn raw_text_close_uses_opening_tag_parts() { + let result = tokenize_and_humanize_parts("let x;"); + assert_eq!( + result, + vec![ + (HtmlTokenType::TagOpenStart, vec!["svg".to_string(), "script".to_string()]), + (HtmlTokenType::TagOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["let x;".to_string()]), + (HtmlTokenType::TagClose, vec!["svg".to_string(), "script".to_string()]), + (HtmlTokenType::Eof, vec![]), + ] + ); + } + + #[test] + fn raw_text_close_is_case_insensitive() { + let result = tokenize_and_humanize_parts(""); + assert_eq!( + result, + vec![ + (HtmlTokenType::TagOpenStart, vec![String::new(), "STYLE".to_string()]), + (HtmlTokenType::TagOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["a".to_string()]), + (HtmlTokenType::TagClose, vec![String::new(), "STYLE".to_string()]), + (HtmlTokenType::Eof, vec![]), + ] + ); + } + + /// The component raw-text close is matched case-insensitively too + /// (`_consumeRawTextWithTagClose` is shared between elements and + /// selectorless components). + #[test] + fn component_raw_text_close_is_case_insensitive() { + let result = tokenize_selectorless_and_humanize_parts("a"); + assert_eq!( + result, + vec![ + ( + HtmlTokenType::ComponentOpenStart, + vec!["MyComp".to_string(), String::new(), "style".to_string()] + ), + (HtmlTokenType::ComponentOpenEnd, vec![]), + (HtmlTokenType::RawText, vec!["a".to_string()]), + ( + HtmlTokenType::ComponentClose, + vec!["MyComp".to_string(), String::new(), "style".to_string()] + ), + (HtmlTokenType::Eof, vec![]), + ] + ); + } +} diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index 0abdd2e7d..025be922a 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -1416,6 +1416,86 @@ mod case_sensitivity { assert!(!errors.is_empty()); assert!(errors[0].contains("Unexpected closing tag")); } + + #[test] + fn should_match_closing_tags_case_sensitive() { + // TS: it('should match closing tags case sensitive', ...) + let allocator = Allocator::default(); + let result = HtmlParser::new(&allocator, "

", "TestComp").parse(); + let errors: Vec<_> = result + .errors + .iter() + .map(|e| (e.msg.split('.').next().unwrap_or_default(), e.span.start.offset)) + .collect(); + assert_eq!( + errors, + vec![("Unexpected closing tag \"p\"", 8), ("Unexpected closing tag \"dIv\"", 12)] + ); + } + + /// An element with an optional end tag is closed by the next one whatever its case. + /// The open element used to be looked up by its lowercased name, never found, and + /// the parser looped forever. + #[test] + fn should_close_capitalised_elements_with_optional_end_tags() { + assert_eq!( + parse_and_humanize("
  • a
  • b
"), + vec![element("UL", 0), element("LI", 1), text("a", 2), element("LI", 1), text("b", 2),] + ); + assert_eq!( + parse_and_humanize("

a

b"), + vec![element("P", 0), text("a", 1), element("P", 0), text("b", 1)] + ); + assert_eq!( + parse_and_humanize("ab"), + vec![element("Tr", 0), element("Td", 1), text("a", 2), element("Td", 1), text("b", 2),] + ); + // The two spellings close each other. + assert_eq!( + parse_and_humanize("

  • a
  • b
  • c"), + vec![ + element("li", 0), + text("a", 1), + element("LI", 0), + text("b", 1), + element("Li", 0), + text("c", 1), + ] + ); + } + + /// `"), + vec![element("STYLE", 0), text("p > a{}", 1)] + ); + assert_eq!( + parse_and_humanize(""), + vec![element("Script", 0), text("if (a < b) {}", 1)] + ); + assert_eq!( + parse_and_humanize(""), + vec![element("TEXTAREA", 0), text("x", 1)] + ); + assert_eq!( + parse_and_humanize("a

    b

    "), + vec![element("Title", 0), text("a", 1), element("p", 0), text("b", 1)] + ); + } + + /// A tag name starts with a letter. `<_x>` is text, so its closing tag closes nothing. + #[test] + fn should_not_start_a_tag_with_an_underscore() { + assert_eq!(parse_and_humanize("a <_b c"), vec![text("a <_b c", 0)]); + let (nodes, errors) = parse_with_errors("<_under>c"); + assert_eq!(nodes, vec![text("<_under>c", 0)]); + assert_eq!(errors.len(), 1); + assert!(errors[0].starts_with("Unexpected closing tag \"_under\"."), "{errors:?}"); + } } // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 7738daf01..314b22a08 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -1259,11 +1259,8 @@ fn test_object_spread_at_end() { #[test] fn test_spread_in_arrow_function_body() { - // Array spread inside an arrow function binding. Arrow functions fall through to the - // ExpressionStore in ingest (not explicitly handled), so the LiteralArray with SpreadElement - // reaches convert_angular_expression_with_ctx directly. Before the fix to the LiteralArray - // arm in reify/angular_expression.rs, SpreadElement entries were silently unwrapped, - // resulting in `() => [ctx.base,"extra"]` instead of `() => [...ctx.base,"extra"]`. + // Array spread inside an arrow function binding: the spread must survive, giving + // `() => [...ctx.base,"extra"]` rather than `() => [ctx.base,"extra"]`. let js = compile_template_to_js( r#""#, "TestComponent", @@ -12139,10 +12136,10 @@ export class D {} ); } -/// An unresolved identifier in a computed host key must be silently dropped — -/// matching existing behavior for any unrecognized host metadata. +/// An unresolved identifier in a computed host key makes the host impossible to +/// evaluate. ngtsc reports it; the entry is not silently dropped. #[test] -fn host_attribute_unknown_identifier_dropped() { +fn host_attribute_unknown_identifier_is_reported() { let allocator = Allocator::default(); let source = r#" import { Directive } from '@angular/core'; @@ -12154,13 +12151,10 @@ import { Directive } from '@angular/core'; export class D {} "#; let result = transform_angular_file(&allocator, "d.ts", source, None, None); - assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); - - let normalized = result.code.replace([' ', '\n', '\t'], ""); - assert!( - !normalized.contains("hostAttrs:"), - "Unresolved identifier must not produce hostAttrs entry.\nCode:\n{}", - result.code + let messages: Vec<&str> = result.diagnostics.iter().map(|d| &*d.message).collect(); + assert_eq!( + messages, + ["Decorator host metadata must be an object Value could not be determined statically."] ); } diff --git a/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs b/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs new file mode 100644 index 000000000..4d207ac14 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/spread_and_tagged_template_test.rs @@ -0,0 +1,115 @@ +//! Spread call arguments and tagged template literals keep their shape and resolve the +//! names they read: `f(1, ...item)` inside a `@for` must not emit `ctx_r0.f(1, ctx.item)`, +//! and `` tag`a${item}` `` must not read `tag` and `item` off the embedded view's `ctx`. +//! +//! The expected strings are ngtsc's output for the same templates, in this emitter's +//! spelling. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + output::emitter::JsEmitter, + parser::html::HtmlParser, + pipeline::{emit::compile_template, ingest::ingest_component}, + transform::html_to_r3::{HtmlToR3Transform, TransformOptions as HtmlTransformOptions}, +}; +use oxc_str::Ident; + +/// Compile a template through the IR pipeline, with whitespace removed. +fn compile_tpl(template: &str) -> String { + let allocator = Allocator::default(); + let parser = HtmlParser::with_expansion_forms(&allocator, template, "test.html"); + let html_result = parser.parse(); + assert!(html_result.errors.is_empty()); + let transformer = HtmlToR3Transform::new( + &allocator, + template, + HtmlTransformOptions { angular_version: None, ..HtmlTransformOptions::default() }, + ); + let r3_result = transformer.transform(&html_result.nodes); + assert!(r3_result.errors.is_empty()); + let mut job = ingest_component(&allocator, Ident::from("TestComponent"), r3_result.nodes); + let result = compile_template(&mut job); + let emitter = JsEmitter::new(); + let mut output = String::new(); + for decl in &result.declarations { + output.push_str(&emitter.emit_statement(decl)); + } + output.push_str(&emitter.emit_statements(&result.template_fn.statements)); + output.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[track_caller] +fn assert_emits(template: &str, expected: &str) { + let code = compile_tpl(template); + assert!(code.contains(expected), "expected `{expected}` for `{template}`, got:\n{code}"); +} + +#[test] +fn spread_argument_is_kept() { + assert_emits("{{ f(...items) }}", "i0.ɵɵtextInterpolate(ctx.f(...ctx.items));"); + assert_emits( + "{{ f(1, ...items, other, ...other) }}", + "i0.ɵɵtextInterpolate(ctx.f(1,...ctx.items,ctx.other,...ctx.other));", + ); +} + +#[test] +fn spread_argument_reads_a_template_variable() { + assert_emits( + "@for (item of items; track item) {{{ f(1, ...item) }}}", + "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();\ + i0.ɵɵtextInterpolate(ctx_r1.f(1,...item_r1));", + ); +} + +#[test] +fn spread_argument_of_a_safe_call() { + assert_emits("{{ f?.(...items) }}", "ctx.f(...ctx.items)"); +} + +#[test] +fn spread_argument_in_a_listener() { + assert_emits( + r#""#, + "returnctx.f(...ctx.items,$event);", + ); +} + +#[test] +fn spread_argument_reads_an_arrow_function_parameter() { + assert_emits( + r#""#, + "returnctx.f((o)=>ctx.f(1,...o));", + ); +} + +#[test] +fn tagged_template_is_emitted_natively() { + assert_emits("{{ tag`a${other}b` }}", "i0.ɵɵtextInterpolate(ctx.tag`a${ctx.other}b`);"); + assert_emits("{{ tag`plain` }}", "i0.ɵɵtextInterpolate(ctx.tag`plain`);"); +} + +#[test] +fn tagged_template_reads_template_variables() { + assert_emits( + "@for (item of items; track item) {{{ tag`a${item}b${other}` }}}", + "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();\ + i0.ɵɵtextInterpolate(ctx_r1.tag`a${item_r1}b${ctx_r1.other}`);", + ); +} + +#[test] +fn tagged_template_reads_arrow_function_parameters() { + assert_emits( + r#""#, + "returnctx.f((o)=>(p)=>ctx.tag`${o}${p}${ctx.other}`);", + ); +} + +/// The elements hold cooked text, so text that would end the literal or start an +/// interpolation is escaped again when the tagged template is printed, matching +/// ngtsc's `escapeForTemplateLiteral` spelling (`${` becomes `$\{`). +#[test] +fn tagged_template_text_is_escaped() { + assert_emits(r"{{ tag`a\`b\${c}d\\e` }}", r"ctx.tag`a\`b$\{c}d\\e`"); +} diff --git a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs new file mode 100644 index 000000000..ea6a093e7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs @@ -0,0 +1,321 @@ +//! `styles` in `@Component` metadata is evaluated statically, as ngtsc's +//! `parseDirectiveStyles` does: a same-file constant, a concatenation, a spread of an +//! array or an array constant gives the same styles as the strings written out. A value +//! that cannot be used is an error on the `styles` expression, never a silent drop. +//! +//! Every expectation is what `@angular/compiler-cli` 22.2.1 produces for the same source +//! (with the chained line of its diagnostics joined by a space, as elsewhere). + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; +use oxc_diagnostics::OxcDiagnostic; + +/// Declared at the top of every source, as in the report. +const PREAMBLE: &str = "import { Component } from '@angular/core'; +import { OTHER } from './other'; +const S = '.a { color: red }'; +const LIST = ['.l { color: red }']; +"; + +fn source(preamble: &str, metadata: &str) -> String { + format!( + "{PREAMBLE}{preamble}\n@Component({{ selector: 'x', template: '', {metadata} }})\n\ + export class X {{}}\n" + ) +} + +fn compile(source: &str) -> (String, Vec) { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "/x/a.ts", + source, + Some(&TransformOptions::default()), + None, + ); + (result.code, result.diagnostics) +} + +/// The whitespace-free `styles` array of the compiled definition, or `None` when the +/// definition has none. +fn compiled_styles(preamble: &str, metadata: &str) -> Option { + let (code, diagnostics) = compile(&source(preamble, metadata)); + assert!(diagnostics.is_empty(), "unexpected diagnostics for `{metadata}`: {diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let definition = &compact[compact.find("i0.ɵɵdefineComponent(").expect("definition")..]; + let definition = &definition[..definition.find("(()=>{").unwrap_or(definition.len())]; + let start = definition.find("styles:[")?; + let end = definition[start..].find("\"]").map(|i| start + i + 2)?; + Some(definition[start..end].to_string()) +} + +/// `metadata` compiles to the styles that `literal` compiles to. +#[track_caller] +fn assert_same_as_literal(preamble: &str, metadata: &str, literal: &str) { + let expected = compiled_styles("", literal); + assert!(expected.is_some(), "`{literal}` should have styles"); + assert_eq!( + compiled_styles(preamble, metadata), + expected, + "`{metadata}` should compile like `{literal}`" + ); +} + +/// Compiling `styles: {value}` reports exactly `message`, on the value. +#[track_caller] +fn assert_error(preamble: &str, value: &str, message: &str) { + let source = source(preamble, &format!("styles: {value}")); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `styles: {value}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, message, "for `styles: {value}`"); + let labels: Vec<&str> = diagnostic + .labels + .iter() + .map(|l| &source[l.offset() as usize..(l.offset() + l.len()) as usize]) + .collect(); + assert_eq!(labels, [value], "label for `styles: {value}`"); +} + +const NOT_STRINGS: &str = "Failed to resolve @Component.styles to a string or an array of strings"; + +fn at(position: usize) -> String { + format!("Failed to resolve styles at position {position} to a string") +} + +// The rows of the report. + +#[test] +fn same_file_constants_already_supported() { + assert_same_as_literal("", "styles: [S]", "styles: ['.a { color: red }']"); + assert_same_as_literal("", "styles: S", "styles: ['.a { color: red }']"); +} + +#[test] +fn concatenation_of_strings() { + assert_same_as_literal("", "styles: ['.b{}' + '.c{}']", "styles: ['.b{}.c{}']"); + assert_same_as_literal( + "const A = '.n'; const B = A + '{}';", + "styles: [B, S + '.k{}']", + "styles: ['.n{}', '.a { color: red }.k{}']", + ); +} + +#[test] +fn spread_of_a_same_file_array() { + assert_same_as_literal("", "styles: [...LIST]", "styles: ['.l { color: red }']"); + assert_same_as_literal( + "", + "styles: ['.first{}', ...LIST, S]", + "styles: ['.first{}', '.l { color: red }', '.a { color: red }']", + ); +} + +#[test] +fn same_file_array_constant() { + assert_same_as_literal("", "styles: LIST", "styles: ['.l { color: red }']"); + assert_same_as_literal("const LISTC = ['.c{}'] as const;", "styles: LISTC", "styles: ['.c{}']"); + assert_same_as_literal( + "const INNER = ['.i{}']; const OUTER = [...INNER, S];", + "styles: OUTER", + "styles: ['.i{}', '.a { color: red }']", + ); +} + +// More that ngtsc evaluates. + +#[test] +fn constants_that_name_other_constants() { + assert_same_as_literal("const A = '.n{}'; const B = A;", "styles: [B]", "styles: ['.n{}']"); +} + +#[test] +fn template_literals() { + assert_same_as_literal("", "styles: [`.t { color: red }`]", "styles: ['.t { color: red }']"); + assert_same_as_literal( + "const C = 'red';", + "styles: [`.t { color: ${C} }`]", + "styles: ['.t { color: red }']", + ); + assert_same_as_literal( + "const C = 'red';", + "styles: `.t { color: ${C} }`", + "styles: ['.t { color: red }']", + ); +} + +/// ngtsc evaluates these too; none of them is an error. +#[test] +fn other_statically_resolvable_values() { + for (preamble, metadata, literal) in [ + ("let L = '.let{}';", "styles: [L]", "styles: ['.let{}']"), + ("function make() { return '.m{}'; }", "styles: [make()]", "styles: ['.m{}']"), + ("const DEV = true;", "styles: [DEV ? '.d{}' : '.p{}']", "styles: ['.d{}']"), + ( + "const C = { s: '.s{}', l: ['.l2{}'] };", + "styles: [C.s, ...C.l]", + "styles: ['.s{}', '.l2{}']", + ), + ] { + assert_same_as_literal(preamble, metadata, literal); + } +} + +#[test] +fn empty_and_blank_styles() { + assert_eq!(compiled_styles("", "styles: []"), None); + assert_eq!(compiled_styles("", "styles: ''"), None); + assert_eq!(compiled_styles("const NONE: string[] = [];", "styles: NONE"), None); + assert_same_as_literal("", "styles: ['', ' ', S]", "styles: ['.a { color: red }']"); +} + +/// A plain literal compiles exactly as it did before styles were evaluated. +#[test] +fn literal_styles_are_unchanged() { + assert_eq!( + compiled_styles("", "styles: ['.x { color: blue }', '.y{}']").as_deref(), + Some(r#"styles:[".x[_ngcontent-%COMP%]{color:blue}",".y[_ngcontent-%COMP%]{}"]"#) + ); + assert_eq!( + compiled_styles("", "styles: '.x { color: blue }'").as_deref(), + Some(r#"styles:[".x[_ngcontent-%COMP%]{color:blue}"]"#) + ); +} + +// Errors: what ngtsc reports, on the `styles` expression. + +#[test] +fn styles_that_are_not_a_string_or_an_array() { + for (value, suffix) in [ + ("1", "Value is of type 'number'."), + ("{}", "Value is of type '{}'."), + ("undefined", "Value is of type 'undefined'."), + ] { + assert_error("", value, &format!("{NOT_STRINGS} {suffix}")); + } +} + +#[test] +fn entry_that_is_not_a_string() { + for (preamble, value, position, suffix) in [ + ("", "[1]", 0, "Value is of type 'number'."), + ("", "[null]", 0, "Value is of type 'null'."), + ("", "[LIST, ['.z{}']]", 0, "Value is of type '[string]'."), + ("declare const X: string;", "[X]", 0, "Value is a reference to 'X'."), + ( + "declare function make(): string;", + "[make()]", + 0, + "Value could not be determined statically.", + ), + ("", "[NOPE]", 0, "Value could not be determined statically."), + ("", "[...S]", 0, "Value could not be determined statically."), + ("enum E { A = '.e{}' }", "[E.A]", 0, "Value is of type 'E'."), + // The first entry that is not a string is the one reported. + ("", "[S, 1, true]", 1, "Value is of type 'number'."), + ] { + assert_error(preamble, value, &format!("{} {suffix}", at(position))); + } +} + +/// ngtsc reads another file to evaluate an import. One file at a time, that is an error +/// that says so, wherever the import appears. +#[test] +fn styles_that_depend_on_an_import() { + let message = "@Component.styles depends on 'OTHER', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files."; + for value in ["[OTHER]", "OTHER", "[S, OTHER]", "[OTHER + '.k{}']", "[...OTHER]"] { + let source = source("", &format!("styles: {value}")); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `styles: {value}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, message, "for `styles: {value}`"); + } +} + +// `styleUrl` and `styleUrls` alongside `styles`. + +#[test] +fn style_urls_alongside_evaluated_styles() { + let source = source("", "styles: [...LIST, S + '.k{}'], styleUrls: ['./a.css']"); + let (code, diagnostics) = compile(&source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + r#"styles:[".l[_ngcontent-%COMP%]{color:red}",".a[_ngcontent-%COMP%]{color:red}.k[_ngcontent-%COMP%]{}"]"# + ), + "{compact}" + ); +} + +/// ngtsc reads `styles` in the component handler, after +/// `extractDirectiveMetadata` has already thrown for the constructor checks. +/// A component with an invalid constructor parameter AND invalid `styles` +/// reports the parameter error, not the styles one. +#[test] +fn constructor_errors_come_before_styles_errors() { + let source = "import { Component, Inject } from '@angular/core'; +import { OTHER } from './other'; +@Component({ selector: 'x', template: '', styles: [OTHER] }) +export class X { + constructor(@Inject() t: unknown) {} +} +"; + let (_, diagnostics) = compile(source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected exactly one diagnostic, got {diagnostics:?}"); + }; + assert!( + diagnostic.message.contains("Unexpected number of arguments to @Inject()"), + "expected the constructor error first, got: {diagnostic:?}" + ); +} + +/// ngtsc keeps `EnumValue` identity across module resolution, so an imported +/// enum member is a wrong-type error, same as a same-file one. The evaluator +/// must not unwrap the enum to its value when a binding crosses files +/// (checked same-file here; the cross-file path shares `Value::Enum`). +#[test] +fn enum_members_are_not_strings() { + let source = "enum E { A = '.a{}' } +import { Component } from '@angular/core'; +const _ = E.A; +@Component({ selector: 'x', template: '', styles: [E.A] }) +export class X {} +"; + let (_, diagnostics) = compile(source); + assert!( + diagnostics.iter().any(|d| d.message.contains("Failed to resolve styles at position 0")), + "expected the position-0 styles error, got {diagnostics:?}" + ); +} + +/// ngtsc folds duplicate `styles` properties into a Map — the last key is the +/// only one `parseDirectiveStyles` sees. +#[test] +fn the_last_styles_property_wins() { + // A bad `styles` a later good one shadows is never read upstream. + let (code, diagnostics) = compile(&source("", "styles: 1, styles: ['.a { color: red }']")); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert!(compiled_styles("", "styles: 1, styles: ['.a { color: red }']").is_some()); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(r#"styles:[".a[_ngcontent-%COMP%]{color:red}"]"#), "{compact}"); + + // A good `styles` a later bad one shadows is fully replaced, error and all. + let (code, diagnostics) = compile(&source("", "styles: ['.a { color: red }'], styles: 1")); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic, got {diagnostics:?}"); + }; + assert_eq!( + diagnostic.message, + "Failed to resolve @Component.styles to a string or an array of strings Value is of type \ + 'number'.", + ); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let definition = &compact[compact.find("i0.ɵɵdefineComponent(").expect("definition")..]; + let definition = &definition[..definition.find("(()=>{").unwrap_or(definition.len())]; + assert!(!definition.contains("styles:"), "{compact}"); +} diff --git a/napi/angular-compiler/.gitignore b/napi/angular-compiler/.gitignore index 9744d17f2..ac27bae0f 100644 --- a/napi/angular-compiler/.gitignore +++ b/napi/angular-compiler/.gitignore @@ -10,3 +10,4 @@ /benchmarks/typedb-web/dist /e2e/app/dist /tsconfig.tsbuildinfo +/test/.hmr-update-module-tmp diff --git a/napi/angular-compiler/e2e/app/src/app/@scoped/at.component.ts b/napi/angular-compiler/e2e/app/src/app/@scoped/at.component.ts new file mode 100644 index 000000000..fbedcfa18 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/@scoped/at.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core' + +/** A component whose file path contains `@`, like one under `node_modules/@scope`. */ +@Component({ + selector: 'app-at', + templateUrl: './at.html', +}) +export class At { + value = 'AT_VALUE' +} diff --git a/napi/angular-compiler/e2e/app/src/app/@scoped/at.html b/napi/angular-compiler/e2e/app/src/app/@scoped/at.html new file mode 100644 index 000000000..2298e1f65 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/@scoped/at.html @@ -0,0 +1 @@ +

    AT_ONE

    diff --git a/napi/angular-compiler/e2e/app/src/app/app.component.ts b/napi/angular-compiler/e2e/app/src/app/app.component.ts index ee2c28635..80d52f906 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.component.ts +++ b/napi/angular-compiler/e2e/app/src/app/app.component.ts @@ -1,17 +1,23 @@ import { Component, signal } from '@angular/core' +import { At } from './@scoped/at.component' import { Card } from './card.component' import { DuoFirst, DuoSecond } from './duo.component' import { InlineCard } from './inline-card.component' +import { Io } from './io.component' +import { Lab } from './lab.component' import { UTIL_VALUE } from './util' @Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', - imports: [Card, InlineCard, DuoFirst, DuoSecond], + imports: [Card, InlineCard, DuoFirst, DuoSecond, Lab, Io, At], }) export class App { protected readonly title = signal('E2E_TITLE') protected readonly utilValue = UTIL_VALUE + protected readonly ioClicked = signal('') + protected readonly ioRenamed = signal(0) + protected readonly ioChecked = signal(false) } diff --git a/napi/angular-compiler/e2e/app/src/app/app.html b/napi/angular-compiler/e2e/app/src/app/app.html index 3e9d268ca..b4abca5af 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.html +++ b/napi/angular-compiler/e2e/app/src/app/app.html @@ -6,4 +6,16 @@

    {{ title() }}

    + PXPYREST + +

    {{ ioClicked() }}|{{ ioRenamed() }}|{{ ioChecked() }}

    + diff --git a/napi/angular-compiler/e2e/app/src/app/io.component.ts b/napi/angular-compiler/e2e/app/src/app/io.component.ts new file mode 100644 index 000000000..c963a13cb --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/io.component.ts @@ -0,0 +1,22 @@ +import { Component, input, model, output } from '@angular/core' + +/** + * A component with every kind of output and an aliased, transformed input, for + * checking that a template hot swap leaves them bound. + */ +@Component({ + selector: 'app-io', + templateUrl: './io.html', +}) +export class Io { + clicked = output() + renamed = output({ alias: 'renamedAlias' }) + checked = model(false) + label = input('', { alias: 'labelAlias', transform: (value: string) => value.toUpperCase() }) + + emit(): void { + this.clicked.emit('CLICKED') + this.renamed.emit(7) + this.checked.set(!this.checked()) + } +} diff --git a/napi/angular-compiler/e2e/app/src/app/io.html b/napi/angular-compiler/e2e/app/src/app/io.html new file mode 100644 index 000000000..515c3af9a --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/io.html @@ -0,0 +1 @@ + diff --git a/napi/angular-compiler/e2e/app/src/app/lab.component.ts b/napi/angular-compiler/e2e/app/src/app/lab.component.ts new file mode 100644 index 000000000..4271d6e7e --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/lab.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' + +/** + * A component whose template the HMR definition tests rewrite: it gains and + * loses elements, bindings, attributes and slots. + */ +@Component({ + selector: 'app-lab', + templateUrl: './lab.html', + // A style block so every template update module also merges `styles`, the + // key the swap otherwise leaves to the `...ɵcmp` spread. + styles: ['.lab-one { font-weight: bold }'], + changeDetection: ChangeDetectionStrategy.Eager, +}) +export class Lab { + a = 'LAB_A' + b = 'LAB_B' +} diff --git a/napi/angular-compiler/e2e/app/src/app/lab.html b/napi/angular-compiler/e2e/app/src/app/lab.html new file mode 100644 index 000000000..eee2bf29f --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/lab.html @@ -0,0 +1 @@ +

    one

    diff --git a/napi/angular-compiler/e2e/compare/fixtures/host-bindings/host-spread-and-constant.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/host-bindings/host-spread-and-constant.fixture.ts new file mode 100644 index 000000000..f25417596 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/host-bindings/host-spread-and-constant.fixture.ts @@ -0,0 +1,149 @@ +/** + * `host` metadata that is not a plain literal: a spread of an object, or a host + * that is itself a constant. ngtsc evaluates the expression statically, so each + * compiles to the same bindings as the entries written out. + * + * Fixtures keep to one plain attribute, because the order of several in + * `hostAttrs` is a separate documented difference (see known-differences.ts). + */ +import type { Fixture } from '../types.js' + +const ALL = `{ '(click)': 'go()', '[title]': 't', '[class.x]': 'on', '[style.width]': 'w', '[attr.aria-label]': 't', role: 'button', class: 'a b', style: 'color: red' }` + +function host( + name: string, + description: string, + preamble: string, + expression: string, + decorator: 'Component' | 'Directive' = 'Component', +): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}${decorator}` + const metadata = + decorator === 'Component' + ? `selector: 'app-${name}', standalone: true, template: '',` + : `selector: '[app-${name}]', standalone: true,` + return { + name, + category: 'host-bindings', + description, + className, + type: 'full-transform', + sourceCode: ` +import { ${decorator} } from '@angular/core'; + +${preamble} + +@${decorator}({ + ${metadata} + host: ${expression}, +}) +export class ${className} { + t = ''; + on = true; + w = ''; + go() {} + other() {} +} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + host( + 'host-spread-before', + 'Spread before other host entries', + `const SHARED = { '(press)': 'go()' };`, + `{ ...SHARED, accessibilityRole: 'button' }`, + ), + host( + 'host-spread-after', + 'Spread after other host entries', + `const SHARED = { '(press)': 'go()' };`, + `{ accessibilityRole: 'button', ...SHARED }`, + ), + host( + 'host-spread-overridden', + 'An entry after the spread overrides one of its keys', + `const SHARED = { role: 'a', '(press)': 'go()' };`, + `{ ...SHARED, role: 'b', '(press)': 'other()' }`, + ), + host( + 'host-spread-overrides', + 'A spread overrides an entry before it', + `const SHARED = { role: 'a' };`, + `{ role: 'b', ...SHARED }`, + ), + host( + 'host-two-spreads', + 'Two spreads', + `const A = { '(press)': 'go()' }; +const B = { '[title]': 't', role: 'x' };`, + `{ ...A, ...B }`, + ), + host( + 'host-spread-of-spread', + 'A spread of a constant that itself spreads another', + `const A = { '(press)': 'go()' }; +const B = { ...A, '[title]': 't' };`, + `{ ...B, role: 'x' }`, + ), + host('host-constant', 'Host that is a constant', `const HOST = { '(press)': 'go()' };`, 'HOST'), + host( + 'host-constant-as-const', + 'Host that is a constant declared `as const`', + `const HOST = { '(press)': 'go()', role: 'x' } as const;`, + 'HOST', + ), + host( + 'host-string-constant-values', + 'Host entries whose values and keys are string constants', + `const HANDLER = 'go()'; +const ROLE = 'button'; +const KEY = '[title]';`, + `{ '(press)': HANDLER, role: ROLE, [KEY]: 't' }`, + ), + host( + 'host-all-kinds-spread', + 'Every kind of key through a spread', + `const ALL = ${ALL};`, + '{ ...ALL }', + ), + host( + 'host-all-kinds-constant', + 'Every kind of key through a constant', + `const ALL = ${ALL};`, + 'ALL', + ), + host( + 'host-directive-all-kinds-spread', + 'Every kind of key through a spread, on a directive', + `const ALL = ${ALL};`, + '{ ...ALL }', + 'Directive', + ), + host( + 'host-directive-all-kinds-constant', + 'Every kind of key through a constant, on a directive', + `const ALL = ${ALL};`, + 'ALL', + 'Directive', + ), + host( + 'host-directive-spread', + 'Spread and an override on a directive', + `const SHARED = { '(click)': 'go()', '[title]': 't', class: 'a' };`, + `{ ...SHARED, role: 'button', class: 'b' }`, + 'Directive', + ), + host( + 'host-other-resolvable-forms', + 'A `let`, a function call, a member access and a conditional spread', + `let BASE = { '(press)': 'go()' }; +function make() { return { '[title]': 't' }; } +const C = { more: { '[class.x]': 'on' } }; +const DEV = true; +const EXTRA = { role: 'x' };`, + `{ ...BASE, ...make(), ...C.more, ...(DEV ? EXTRA : {}) }`, + ), +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index 3de24beb5..23c73f3d0 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -324,4 +324,8 @@ export const KNOWN_DIFFERENCES: Record = { fields: ['OnPushNoneStyleComponent.ɵcmp'], reasons: [CHANGE_DETECTION_V22], }, + 'templates/ng-arrow-function-pipe': { + fields: ['TestPipe.ɵfac'], + reasons: [FACTORY], + }, } diff --git a/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts new file mode 100644 index 000000000..36cc8e269 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts @@ -0,0 +1,55 @@ +/** + * `styles` values that are not string literals: ngtsc evaluates them statically. + */ +import type { Fixture } from '../types.js' + +const fixture = (name: string, description: string, declarations: string, styles: string) => ({ + name: `styles-evaluated-${name}`, + category: 'styles', + description, + className: 'StylesComponent', + type: 'full-transform' as const, + sourceCode: ` +import { Component } from '@angular/core'; + +const S = '.a { color: red }'; +const LIST = ['.l { color: red }']; +${declarations} + +@Component({ + selector: 'app-styles', + standalone: true, + template: '

    x

    ', + styles: ${styles}, +}) +export class StylesComponent {} +`, + expectedFeatures: ['ɵɵdefineComponent'], +}) + +export const fixtures: Fixture[] = [ + fixture('constant', 'A string constant in the array', '', '[S]'), + fixture('constant-alone', 'A string constant as the styles', '', 'S'), + fixture('concatenation', 'A concatenation of strings', '', `['.b{}' + '.c{}', S + '.k{}']`), + fixture('spread', 'A spread of an array constant', '', `['.first{}', ...LIST, S]`), + fixture('array-constant', 'An array constant as the styles', '', 'LIST'), + fixture( + 'nested-constants', + 'Constants that are built from other constants', + `const INNER = ['.i{}']; const OUTER = [...INNER, S]; const A = '.n'; const B = A + '{}';`, + '[...OUTER, B]', + ), + fixture( + 'template-literal', + 'A template literal with a constant substitution', + `const C = 'red';`, + '[`.t { color: ${C} }`]', + ), + fixture( + 'call-and-member', + 'A same-file function call, a ternary and members of an object constant', + `function make() { return '.m{}'; } const DEV = true; const C = { s: '.s{}', l: ['.l2{}'] };`, + `[make(), DEV ? '.d{}' : '.p{}', C.s, ...C.l]`, + ), + fixture('blank-entries', 'Empty and blank entries are left out', '', `['', ' ', S]`), +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts new file mode 100644 index 000000000..8eb715d12 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts @@ -0,0 +1,507 @@ +/** + * Arrow functions in template expressions. + * + * A name read inside an arrow body resolves to the arrow's own parameter (or + * an enclosing arrow's) before template variables and the component context. + * + * In event bindings the arrow stays in place. Everywhere else it is hoisted to + * a constant and instantiated through ɵɵarrowFunction. + * + * The `ng-*` fixtures are ported from Angular's compliance suite: + * packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_arrow_functions + */ +import type { Fixture } from '../types.js' + +const MEMBERS = ` + open = signal(false); + items: any[] = []; + other = 1; + fallback() { return true; } + run(fn: any): any {} +` + +function arrow(name: string, description: string, template: string, members = MEMBERS): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}Component` + return { + name, + category: 'templates', + description, + className, + type: 'full-transform', + sourceCode: ` +import { Component, signal } from '@angular/core'; + +@Component({ + selector: 'app-${name}', + standalone: true, + template: \`${template}\`, +}) +export class ${className} {${members}} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + // Event bindings: the arrow stays in place inside the listener. + arrow( + 'arrow-listener-one-param', + 'Listener arrow reading its own parameter', + ``, + ), + arrow( + 'arrow-listener-no-param', + 'Listener arrow reading only the component', + ``, + ), + arrow( + 'arrow-listener-multiple-params', + 'Listener arrow reading several parameters', + ``, + ), + arrow( + 'arrow-listener-param-shadows-property', + 'Listener arrow parameter named like a component property', + ``, + ), + arrow( + 'arrow-listener-param-and-property', + 'Listener arrow reading a parameter and a component property', + ``, + ), + arrow( + 'arrow-listener-nested', + 'Nested listener arrow reading the outer parameter', + ``, + ), + arrow( + 'arrow-listener-param-shadows-let', + 'Listener arrow parameter shadowing an @let', + `@let v = 1; {{ v }}`, + ), + arrow( + 'arrow-listener-param-shadows-for-item', + 'Listener arrow parameter shadowing a @for item', + `@for (item of items; track item) { }`, + ), + arrow( + 'arrow-listener-param-shadows-ref', + 'Listener arrow parameter shadowing a template reference', + ``, + ), + arrow( + 'arrow-listener-reads-template-variables', + 'Listener arrow reading a parameter, a @for item, a reference and the component', + `@for (item of items; track item) { }`, + ), + + // Ported from Angular's compliance suite. + arrow( + 'ng-arrow-function-no-context', + 'arrow_function_no_context.ts', + ` + + + + `, + ` + sigA = signal(1); + sigB = signal(2); +`, + ), + arrow( + 'ng-arrow-function-dollar-event', + 'arrow_function_dollar_event.ts', + ` + @let topLevelLet = 1; + @if (true) { + @let innerLet = 2; + + } + `, + ` + componentProp = 0; + result = signal(''); +`, + ), + arrow( + 'ng-arrow-function-nested-listeners', + 'arrow_function_nested_listeners.ts', + ` + @let a = 1; + @if (true) { + + @if (true) { + @let c = 3; + + } + } + `, + ` + someSignal = signal(''); + componentProp = 0; +`, + ), + { + name: 'ng-arrow-function-host-listener', + category: 'templates', + description: 'arrow_function_host_listener.ts', + className: 'TestDir', + type: 'full-transform', + sourceCode: ` +import { Directive, signal } from '@angular/core'; + +@Directive({ + selector: '[appTestDir]', + standalone: true, + host: { + '(click)': 'someSignal.update(prev => prev + 1)', + '(mousedown)': 'someSignal.update(() => componentProp + 1)', + }, +}) +export class TestDir { + someSignal = signal(0); + componentProp = 1; +} + `.trim(), + expectedFeatures: ['ɵɵlistener'], + }, + + // Property bindings, interpolations and @let: the arrow is hoisted via ɵɵarrowFunction. + arrow( + 'arrow-property-one-param', + 'Property binding arrow reading its own parameter', + `
    `, + ), + arrow( + 'arrow-property-no-param', + 'Property binding arrow reading only the component', + `
    `, + ), + arrow( + 'arrow-property-param-shadows-property', + 'Property binding arrow parameter named like a component property', + `
    `, + ), + arrow( + 'arrow-property-param-and-property', + 'Property binding arrow reading a parameter and a component property', + `
    `, + ), + arrow( + 'arrow-property-nested', + 'Nested property binding arrow reading the outer parameter', + `
    `, + ), + arrow( + 'arrow-property-param-shadows-let', + 'Property binding arrow parameter shadowing an @let', + `@let v = 1;
    {{ v }}`, + ), + arrow( + 'arrow-property-param-shadows-for-item', + 'Property binding arrow parameter shadowing a @for item', + `@for (item of items; track item) {
    }`, + ), + arrow( + 'arrow-property-param-shadows-ref', + 'Property binding arrow parameter shadowing a template reference', + `
    `, + ), + arrow( + 'arrow-property-reads-template-variables', + 'Property binding arrow reading a parameter, a @for item, a reference and the component', + `@for (item of items; track item) {
    }`, + ), + arrow( + 'arrow-property-several-bindings', + 'Arrows in class, style and attribute bindings on one element', + `
    `, + ), + arrow( + 'arrow-control-flow-expressions', + 'Arrows in @if, @for and @switch expressions', + `@if (run((o) => !o)) { a } @for (i of run((o) => o); track i) { {{ run((j) => j + i) }} } @switch (run((o) => o)) { @case (run((p) => p)) { b } }`, + ), + arrow( + 'arrow-interpolation-one-param', + 'Interpolation arrow reading its own parameter', + `{{ run((o) => !o) }}`, + ), + arrow( + 'arrow-interpolation-multiple-params', + 'Interpolation arrow reading several parameters', + `{{ run((a, b) => a + b) }}`, + ), + arrow( + 'arrow-interpolation-param-and-property', + 'Interpolation arrow reading a parameter and a component property', + `{{ run((o) => o || fallback()) }}`, + ), + arrow( + 'arrow-interpolation-no-param', + 'Interpolation arrow reading only the component', + `{{ run(() => !open()) }}`, + ), + arrow( + 'arrow-interpolation-identical-arrows', + 'Two identical arrows share one hoisted constant', + `{{ run((o) => !o) }}{{ run((o) => !o) }}`, + ), + arrow( + 'arrow-hoisted-root-reference', + 'Hoisted arrow reading a template reference in the root view', + `
    `, + ), + arrow( + 'arrow-hoisted-if-alias', + 'Hoisted arrow reading an @if alias', + `@if (open(); as alias) {
    }`, + ), + arrow( + 'arrow-hoisted-ng-template-variables', + 'Hoisted arrow reading ng-template context variables', + `
    `, + ), + arrow( + 'arrow-hoisted-two-in-one-view', + 'Two hoisted arrows in one embedded view', + `@for (item of items; track item) {
    }`, + ), + arrow( + 'arrow-hoisted-nested-views', + 'Hoisted arrow three views deep reading a @for item and a reference', + `@for (item of items; track item) { @if (other) { @if (open()) { {{ run((p) => p + item + el.value + other) }} } } }`, + ), + arrow( + 'arrow-hoisted-same-arrow-in-two-views', + 'Identical arrows in different views share one constant', + `{{ run((o) => !o) }} @if (other) { {{ run((o) => !o) }} }`, + ), + arrow( + 'arrow-hoisted-order', + 'Hoisted constants are numbered in template order across views', + `
    @if (other) { {{ run((b) => b) }} }
    @for (i of items; track i) { {{ run((d) => d + i) }} }`, + ), + arrow( + 'arrow-hoisted-i18n', + 'Hoisted arrow in an i18n interpolation', + `
    Hello {{ run((o) => o + other) }}
    `, + ), + arrow( + 'arrow-let-reading-let', + '@let arrow reading another @let', + `@let base = 2; @let fn = (a) => a + base; {{ fn(1) }}`, + ), + arrow( + 'arrow-let-chain', + '@let arrows calling each other', + `@let a = other; @let f = () => a; @let g = () => f() + a; {{ g() }}`, + ), + arrow( + 'arrow-let-in-for', + '@let arrow declared in a @for and used in a child view', + `@for (item of items; track item) { @let f = () => item; {{ f() }} @if (other) { {{ f() }} } }`, + ), + + // Hoisted cases ported from Angular's compliance suite. + arrow( + 'ng-arrow-function-top-level-context', + 'arrow_function_top_level_context.ts', + `{{(param => param + value + 1)('param')}}`, + ` + value = 0; +`, + ), + arrow( + 'ng-arrow-function-this-access', + 'arrow_function_this_access.ts', + `{{((a, b) => a + this.a + b + this.b)(1, 3)}}`, + ` + a = 2; + b = 4; +`, + ), + arrow( + 'ng-arrow-function-defined-let', + 'arrow_function_defined_let.ts', + ` + @let fn = (a, b) => componentValue + a + b; + One: {{fn(0, 1)}} + @if (true) { + Two: {{fn(1, 1)}} + + } + `, + ` + componentValue = 0; +`, + ), + arrow( + 'ng-arrow-function-loop-variables', + 'arrow_function_loop_variables.ts', + ` + @for (item of items; track $index; let outerEven = $even) { + @for (subitem of item.subItems; track $index) { + {{(() => outerEven || $even || $index)()}} + } + } + `, + ` + items = [{ name: 'one', subItems: ['sub one', 'sub two'] }]; +`, + ), + arrow( + 'ng-arrow-function-let-nested', + 'arrow_function_let_nested.ts', + ` + @let a = 1; + @if (true) { + @let b = 2; + @if (true) { + @let c = 3; + {{(() => a + b + c)()}} + } + } + `, + ` + +`, + ), + arrow( + 'ng-arrow-function-inside-pure-value', + 'arrow_function_inside_pure_value.ts', + ` + {{[(a) => a + 1][0](1000)}} + {{[(a) => a + 1 + componentProp][0](1000)}} + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-pure-return-values', + 'arrow_function_pure_return_values.ts', + ` + {{(a => ({foo: a, bar: componentProp}))(1).foo}} + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-no-context', + 'arrow_function_returning_arrow_function_no_context.ts', + `{{(a => b => c => d => a + b + c + d)(1)(2)(3)(4)}}`, + ` + +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-top-level-context', + 'arrow_function_returning_arrow_function_top_level_context.ts', + `{{(a => b => c => d => a + b + c + d + componentProp)(1)(2)(3)(4)}}`, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-returning-arrow-function-nested-context', + 'arrow_function_returning_arrow_function_nested_context.ts', + ` + @let topLevelLet = 1; + @if (true) { + @let nestedLet = 2; + @if (true) { + {{(a => b => c => d => a + b + c + d + componentProp + topLevelLet + nestedLet)(1)(2)(3)(4)}} + } + } + `, + ` + componentProp = 0; +`, + ), + arrow( + 'ng-arrow-function-safe-access', + 'arrow_function_safe_access.ts', + ` + {{(value => value?.a?.b?.c?.()?.()?.()?.())(componentProp)}} +
    + {{() => componentProp?.a?.b?.c?.()?.()?.()?.()}} + `, + ` + componentProp: {a?: {b?: {c?: () => () => () => () => string}}} = {}; +`, + ), + arrow( + 'ng-arrow-function-safe-access-nested-views', + 'arrow_function_safe_access_nested_views.ts', + ` + @if (true) { + @if (true) { + @if (true) { + {{() => componentProp?.a?.b?.c?.()?.()?.()?.()}} + } + } + } + `, + ` + componentProp: {a?: {b?: {c?: () => () => () => () => string}}} = {}; +`, + ), + { + name: 'ng-arrow-function-pipe', + category: 'templates', + description: 'arrow_function_pipe.ts', + className: 'TestComp', + type: 'full-transform', + sourceCode: ` +import { Component, Pipe } from '@angular/core'; + +@Pipe({ name: 'test', standalone: true }) +export class TestPipe { + transform(value: Function) { + return value; + } +} + +@Component({ + selector: 'app-test-comp', + standalone: true, + template: \` + {{(a, b) => a + b | test}} +
    + {{(a, b) => a + b + componentProp | test}} + \`, + imports: [TestPipe], +}) +export class TestComp { + componentProp = 0; +} + `.trim(), + expectedFeatures: ['ɵɵarrowFunction'], + }, + { + name: 'ng-arrow-function-host-binding', + category: 'templates', + description: 'arrow_function_host_binding.ts', + className: 'TestDir', + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[appTestDir]', + standalone: true, + host: { + '[attr.no-context]': '((a, b) => a / b)(5, 10)', + '[attr.with-context]': '((a, b) => a / b + componentProp)(6, 12)', + }, +}) +export class TestDir { + componentProp = 1; +} + `.trim(), + expectedFeatures: ['ɵɵarrowFunction'], + }, +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts new file mode 100644 index 000000000..b32d58c77 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/element-name-case.fixture.ts @@ -0,0 +1,128 @@ +/** + * Element names are case-sensitive and kept as written: `` is an element + * named "View", not a component reference to resolve later. Known HTML elements + * written in capitals keep their spelling too, while still behaving like the + * element they name (void, raw text, implied namespace). + */ +import type { Fixture } from '../types.js' + +const MEMBERS = ` + t = ''; + on = true; + items: any[] = []; + go(..._args: any[]) {} +` + +function fixture(name: string, description: string, template: string, extra = ''): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}Component` + return { + name, + category: 'templates', + description, + className, + type: 'full-transform', + sourceCode: ` +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +${extra} +@Component({ + selector: 'app-${name}', + standalone: true, + schemas: [CUSTOM_ELEMENTS_SCHEMA],${extra ? '\n imports: [ViewCmp],' : ''} + template: \`${template}\`, +}) +export class ${className} {${MEMBERS}} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + fixture( + 'case-root', + 'Capitalised element at the root with a lowercase child', + 'hi', + ), + fixture( + 'case-nested', + 'Capitalised element nested in a lowercase element', + '
    ab
    ', + ), + fixture( + 'case-mixed', + 'Mixed-case element names', + 'abdef', + ), + fixture( + 'case-attributes', + 'Capitalised element with attributes, bindings, listeners and a reference', + '{{ t }}', + ), + fixture( + 'case-matches-component', + 'Capitalised element matching a component selector in imports', + 'projected', + `import { input, output } from '@angular/core'; + +@Component({ selector: 'View', standalone: true, template: '' }) +export class ViewCmp { + label = input(''); + press = output(); +} +`, + ), + fixture( + 'case-self-closing', + 'Self-closing capitalised elements', + '
    ', + ), + fixture( + 'case-control-flow', + 'Capitalised elements inside @if, @for and ng-template', + '@if (on) { a } @else { b } @for (i of items; track i) { {{ i }} } @empty { } cd', + ), + fixture( + 'case-html-elements', + 'Known HTML elements written in capitals', + '
    a
    b
    • d
    • e

    p

    q', + ), + fixture( + 'case-html-void-elements', + 'Void HTML elements written in capitals', + '


    after', + ), + fixture( + 'case-html-security', + 'Security-sensitive bindings on HTML elements written in capitals', + 'a
    ', + ), + fixture( + 'case-svg', + 'Mixed-case names inside and outside svg', + '
    x
    y
    x', + ), + fixture( + 'case-ng-names', + 'ng-content matches in any case; ng-container and ng-template only as written', + 'abc', + ), + fixture( + 'case-raw-text', + 'Raw text elements written in capitals', + '{{ t }}

    after

    ', + ), + fixture( + 'case-optional-end-tags', + 'Capitalised elements with optional end tags', + '
    • a
    • b
    ab
    c

    p

    q', + ), + fixture( + 'case-i18n', + 'Capitalised element with i18n', + 'hello there', + ), + fixture( + 'case-lowercase-unchanged', + 'Lowercase elements, custom and standard', + 'hia

    b
    ', + ), +] diff --git a/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts new file mode 100644 index 000000000..151b79024 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/spread-and-tagged-templates.fixture.ts @@ -0,0 +1,128 @@ +/** + * Spread call arguments and tagged template literals. + * + * Both must keep their shape (`...arg`, the tag) and resolve the names they + * read like any other expression: template variables first, then the component. + */ +import type { Fixture } from '../types.js' + +/** Escapes text so it can sit inside a backtick-quoted string in the generated source. */ +function escapeForTemplateLiteral(text: string): string { + return text.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('${', '\\${') +} + +function fixture(name: string, description: string, template: string): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _dash, c) => c.toUpperCase())}Component` + return { + name, + category: 'templates', + description, + className, + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; +import { JsonPipe, SlicePipe } from '@angular/common'; + +@Component({ + selector: 'app-${name}', + standalone: true, + imports: [JsonPipe, SlicePipe], + template: \`${escapeForTemplateLiteral(template)}\`, +}) +export class ${className} { + items: any[] = []; + other: any = 1; + f(...args: any[]): any {} + tag(...args: any[]): any {} +} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + fixture('spread-argument', 'Spread call argument', '{{ f(...items) }}'), + fixture( + 'spread-argument-mixed', + 'Spread call argument among other arguments', + '{{ f(1, ...items, other, ...other) }}', + ), + fixture( + 'spread-argument-template-variable', + 'Spread call argument reading a @for item', + '@for (item of items; track item) { {{ f(1, ...item) }} }', + ), + fixture( + 'spread-argument-safe-call', + 'Spread argument of a safe call', + '@for (item of items; track item) { {{ f?.(...item) }} }', + ), + fixture( + 'spread-argument-listener', + 'Spread call argument in a listener', + '@for (item of items; track item) { }', + ), + fixture( + 'spread-argument-arrow-parameter', + 'Spread call argument reading an arrow function parameter', + '{{ f((p) => f(...p, other)) }}', + ), + fixture('tagged-template', 'Tagged template literal', '{{ tag`a${other}b` }}'), + fixture( + 'tagged-template-template-variable', + 'Tagged template literal reading a @for item', + '@for (item of items; track item) { {{ tag`a${item}b${other}` }} }', + ), + fixture( + 'tagged-template-listener', + 'Tagged template literal in a listener', + '@for (item of items; track item) { }', + ), + fixture( + 'tagged-template-arrow-parameter', + 'Tagged template literal reading an arrow function parameter', + '{{ f((q) => tag`${q}`) }}', + ), + fixture( + 'spread-argument-pipe', + 'Spread call argument whose operand uses a pipe', + '{{ f(...(items | slice: 1)) }}', + ), + fixture( + 'tagged-template-escapes', + 'Tagged template literal whose text needs escaping', + '{{ tag`a\\`b\\${c}d\\\\e ${other} $x` }}', + ), + fixture( + 'tagged-template-pipe', + 'Tagged template literal whose expression uses a pipe', + '{{ tag`a${other | json}b` }}', + ), + { + name: 'spread-and-tagged-template-host', + category: 'templates', + description: 'Spread arguments and tagged templates in host bindings', + className: 'SpreadAndTaggedHostDirective', + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[appSpreadAndTaggedHost]', + standalone: true, + host: { + '[attr.a]': 'f(...items)', + '[attr.b]': 'tag\`a\${other}\`', + '(click)': 'f((o) => tag\`\${o}\`, (p) => \`\${p}\`, ...items)', + }, +}) +export class SpreadAndTaggedHostDirective { + other: any = 1; + items: any[] = []; + f(...args: any[]): any {} + tag(...args: any[]): any {} +} + `.trim(), + expectedFeatures: [], + }, +] diff --git a/napi/angular-compiler/e2e/compare/src/discovery/finder.ts b/napi/angular-compiler/e2e/compare/src/discovery/finder.ts index bcf8c2fa7..4e80f174b 100644 --- a/napi/angular-compiler/e2e/compare/src/discovery/finder.ts +++ b/napi/angular-compiler/e2e/compare/src/discovery/finder.ts @@ -105,6 +105,9 @@ async function extractComponentsFromFile( template: ts.template, templateUrl: ts.templateUrl, styles: ts.styles || [], + // The TypeScript extractor reads `styles` itself, so they are always + // resolved here. + stylesResolved: true, styleUrls: ts.styleUrls || [], standalone: ts.standalone ?? true, encapsulation: ts.encapsulation || 'Emulated', diff --git a/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts b/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts index ade518ad1..08866f3d6 100644 --- a/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts +++ b/napi/angular-compiler/e2e/tests/hmr-input-component.spec.ts @@ -40,9 +40,9 @@ test.describe('Input Component HMR', () => { }) expect(titleColor).toBe('rgb(255, 0, 0)') - // Verify input bindings still work after HMR update - // This exercises the inputConfig conditional in update_module.rs: - // If inputs were corrupted, these values would be missing or wrong + // Verify input bindings still work after HMR update. The update module + // copies the live definition, so its inputs are never converted again: + // if they were corrupted, these values would be missing or wrong await expect(page.locator('.card-title')).toContainText('INPUT_TITLE') await expect(page.locator('.card-value')).toContainText('42') }) diff --git a/napi/angular-compiler/e2e/tests/hmr-update-definition.spec.ts b/napi/angular-compiler/e2e/tests/hmr-update-definition.spec.ts new file mode 100644 index 000000000..8611da6a1 --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-update-definition.spec.ts @@ -0,0 +1,265 @@ +import type { Page } from '@playwright/test' + +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * A template hot swap must leave the component with the definition a fresh + * compile of the new template would give it: sized for the new template + * (`decls`, `vars`, `consts`, `ngContentSelectors`), and otherwise untouched + * (`inputs`, `outputs`, change detection). + * + * The update module used to spread the live definition back through + * `ɵɵdefineComponent`. That kept the old template's counts and selectors, so a + * swap that added an element or binding rebuilt a view of the wrong size, and + * it converted `outputs` a second time, turning them round. + */ + +/** + * The dev server's watcher drops a change that follows another change to the + * same file within 50ms, and a hot swap can be on screen sooner than that. Wait + * this long before editing a file a second time. + */ +const WATCHER_THROTTLE_MS = 200 + +/** The parts of a rendered component's definition these tests care about. */ +async function definition(page: Page, selector: string) { + return await page.evaluate((selector) => { + const element = document.querySelector(selector)! + const def = (window as any).ng.getComponent(element).constructor.ɵcmp + // `consts` may be a thunk when the template has i18n initializers — its + // `.length` would be the function's arity, not the entry count. + const consts = typeof def.consts === 'function' ? def.consts() : def.consts + return { + decls: def.decls as number, + vars: def.vars as number, + consts: (consts?.length ?? null) as number | null, + ngContentSelectors: (def.ngContentSelectors ?? null) as string[] | null, + styles: (def.styles ?? null) as string[] | null, + outputs: { ...def.outputs } as Record, + inputs: Object.fromEntries( + Object.entries(def.inputs as Record).map(([name, value]) => [ + name, + value.slice(0, 2), + ]), + ), + onPush: def.onPush as boolean, + } + }, selector) +} + +test.describe('HMR update definition', () => { + let errors: string[] + + test.beforeEach(async ({ page, hmrDetector }) => { + errors = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + // The DOM sentinel alone cannot prove no reload was requested: Vite's + // client drops a `full-reload` payload whose `.html` path does not match + // `location.pathname`, so the sentinel survives it. Record the wire. + await hmrDetector.captureWirePayloads() + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test.afterEach(async ({ hmrDetector }) => { + const payloads = await hmrDetector.getWirePayloads() + expect(payloads.map((p) => p.type)).not.toContain('full-reload') + }) + + test('a template that gains an element and a binding, then loses both', async ({ + page, + fileModifier, + hmrDetector, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('app-lab .lab-one')).toHaveText('one') + expect(await definition(page, 'app-lab')).toMatchObject({ decls: 2, vars: 0 }) + + await fileModifier.modifyFile( + 'lab.html', + () => + '

    one

    {{ a }}

    three\n', + ) + + await expect(page.locator('app-lab .lab-two')).toHaveText('LAB_A') + await expect(page.locator('app-lab .lab-three')).toHaveAttribute('title', 'LAB_B') + await expect(page.locator('app-lab .lab-one')).toHaveText('one') + const grown = await definition(page, 'app-lab') + expect(grown).toMatchObject({ decls: 6, vars: 2 }) + // The update module carries the component's styles into the merged + // definition — the spread alone would keep them, but an update module + // that regressed to emitting none would drop them. + expect(grown.styles?.join('')).toContain('.lab-one') + await expect(page.locator('app-lab .lab-one')).toHaveCSS('font-weight', '700') + + // A second swap on the same component, down to less than it started with. + await page.waitForTimeout(WATCHER_THROTTLE_MS) + await fileModifier.modifyFile('lab.html', () => 'min\n') + + await expect(page.locator('app-lab .lab-min')).toHaveText('min') + await expect(page.locator('app-lab .lab-two')).toHaveCount(0) + await expect(page.locator('app-lab .lab-three')).toHaveCount(0) + expect(await definition(page, 'app-lab')).toMatchObject({ decls: 2, vars: 0 }) + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + expect(errors).toEqual([]) + }) + + test('a template that gains, changes and loses an ', async ({ + page, + fileModifier, + hmrDetector, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('app-lab .lab-px')).toHaveCount(0) + expect(await definition(page, 'app-lab')).toMatchObject({ ngContentSelectors: null }) + + await fileModifier.modifyFile( + 'lab.html', + () => '

    one

    \n', + ) + + await expect(page.locator('app-lab .lab-px')).toHaveText('PX') + await expect(page.locator('app-lab .lab-py')).toHaveCount(0) + expect(await definition(page, 'app-lab')).toMatchObject({ ngContentSelectors: ['[x]'] }) + + await page.waitForTimeout(WATCHER_THROTTLE_MS) + await fileModifier.modifyFile( + 'lab.html', + () => '

    one

    \n', + ) + + await expect(page.locator('app-lab .lab-py')).toHaveText('PY') + await expect(page.locator('app-lab .lab-px')).toHaveText('PX') + await expect(page.locator('app-lab .lab-rest')).toHaveText('REST') + await expect(page.locator('app-lab')).toHaveText('PYonePXREST') + expect(await definition(page, 'app-lab')).toMatchObject({ ngContentSelectors: ['[y]', '*'] }) + + await page.waitForTimeout(WATCHER_THROTTLE_MS) + await fileModifier.modifyFile('lab.html', () => '

    one

    \n') + + await expect(page.locator('app-lab')).toHaveText('one') + await expect(page.locator('app-lab .lab-py')).toHaveCount(0) + expect(await definition(page, 'app-lab')).toMatchObject({ ngContentSelectors: null }) + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + expect(errors).toEqual([]) + }) + + test('a template whose consts change', async ({ page, fileModifier, hmrDetector }) => { + const sentinelId = await hmrDetector.addSentinel() + expect(await definition(page, 'app-lab')).toMatchObject({ consts: 1 }) + + await fileModifier.modifyFile( + 'lab.html', + () => + '

    one

    {{ ref.value }}\n', + ) + + await expect(page.locator('app-lab #lab-id')).toHaveAttribute('data-k', 'v') + await expect(page.locator('app-lab .lab-input')).toHaveAttribute('type', 'text') + await expect(page.locator('app-lab .lab-ref')).toHaveText('typed') + expect(await definition(page, 'app-lab')).toMatchObject({ consts: 4 }) + + // No attributes at all: the old consts must not linger. + await page.waitForTimeout(WATCHER_THROTTLE_MS) + await fileModifier.modifyFile('lab.html', () => '

    plain

    \n') + + await expect(page.locator('app-lab p')).toHaveText('plain') + await expect(page.locator('app-lab p')).not.toHaveAttribute('class') + expect(await definition(page, 'app-lab')).toMatchObject({ consts: null }) + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + expect(errors).toEqual([]) + }) + + test('outputs and inputs survive a swap and still reach the parent', async ({ + page, + fileModifier, + hmrDetector, + }) => { + const sentinelId = await hmrDetector.addSentinel() + const before = await definition(page, 'app-io') + expect(before.outputs).toEqual({ + clicked: 'clicked', + renamedAlias: 'renamed', + checkedChange: 'checked', + }) + expect(before.inputs.labelAlias[0]).toBe('label') + await expect(page.locator('.io-emit')).toHaveText('IO') + await expect(page.locator('.io-result')).toHaveText('|0|false') + + await fileModifier.modifyFile( + 'io.html', + () => + '{{ checked() }}\n', + ) + + await expect(page.locator('.io-checked')).toHaveText('false') + // The aliased input kept its transform. + await expect(page.locator('.io-emit')).toHaveText('IO') + let after = await definition(page, 'app-io') + expect(after.outputs).toEqual(before.outputs) + expect(after.inputs).toEqual(before.inputs) + + // output(), the aliased output and the model's change event all reach the parent. + await page.locator('.io-emit').click() + await expect(page.locator('.io-result')).toHaveText('CLICKED|7|true') + await expect(page.locator('.io-checked')).toHaveText('true') + + // A second swap. + await page.waitForTimeout(WATCHER_THROTTLE_MS) + await fileModifier.modifyFile( + 'io.html', + () => '\n', + ) + + await expect(page.locator('.io-emit')).toHaveText('again IO') + after = await definition(page, 'app-io') + expect(after.outputs).toEqual(before.outputs) + expect(after.inputs).toEqual(before.inputs) + + await page.locator('.io-emit').click() + await expect(page.locator('.io-result')).toHaveText('CLICKED|7|false') + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + expect(errors).toEqual([]) + }) + + test('the change detection strategy survives a swap', async ({ page, fileModifier }) => { + expect((await definition(page, 'app-lab')).onPush).toBe(false) + expect((await definition(page, 'app-io')).onPush).toBe(true) + + await fileModifier.modifyFile('lab.html', () => '

    swapped

    \n') + await fileModifier.modifyFile( + 'io.html', + () => '\n', + ) + + await expect(page.locator('app-lab .lab-one')).toHaveText('swapped') + await expect(page.locator('.io-emit')).toHaveText('swapped') + expect((await definition(page, 'app-lab')).onPush).toBe(false) + expect((await definition(page, 'app-io')).onPush).toBe(true) + expect(errors).toEqual([]) + }) + + test('a component whose path contains `@`', async ({ page, fileModifier, hmrDetector }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('.at-text')).toHaveText('AT_ONE') + + await fileModifier.modifyFile( + '@scoped/at.html', + () => '

    AT_TWO

    {{ value }}\n', + ) + + await expect(page.locator('.at-text')).toHaveText('AT_TWO') + await expect(page.locator('.at-value')).toHaveText('AT_VALUE') + expect(await definition(page, 'app-at')).toMatchObject({ decls: 4, vars: 1 }) + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + expect(errors).toEqual([]) + }) +}) diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 52666dc58..c64d16050 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -349,6 +349,12 @@ export interface ExtractedComponentMetadata { templateUrl?: string /** Inline styles array. */ styles: Array + /** + * `false` when `styles` could not be statically evaluated (e.g. it + * references an imported value): `styles` is then empty and must be + * treated as "unknown", not as "the component has no styles". + */ + stylesResolved: boolean /** URLs to external stylesheet files. */ styleUrls: Array /** Whether this is a standalone component. */ @@ -498,12 +504,23 @@ export interface FactoryNapiCompileResult { * This generates a JavaScript module that can be dynamically imported * during HMR to update a component's template and styles. * + * This knows only what it is given: the generated module replaces `template` + * and `consts`, but `decls`, `vars` and `ngContentSelectors` stay as the live + * definition has them, so it is only correct when the new template needs the + * same ones. For a template that changes them, use `compileForHmrSync`, which + * compiles the template and writes every field it decides. + * * # Arguments * * * `component_id` - The component ID (path@ClassName) - * * `template_js` - The compiled template function as JavaScript + * * `template_js` - The compiled template function as JavaScript, or an empty + * string for a styles-only update * * `styles` - The component's CSS styles, or `None` when unknown. An empty * array is definitive and emits `styles: []`, clearing the old styles. + * * `declarations_js` - Optional constant declarations the template references + * * `consts_js` - The new template's consts array as JavaScript. When + * `template_js` is given, `None` emits `consts: null`, clearing the old + * template's consts. * * # Returns * diff --git a/napi/angular-compiler/package.json b/napi/angular-compiler/package.json index 4e0740f9d..5cbc749ca 100644 --- a/napi/angular-compiler/package.json +++ b/napi/angular-compiler/package.json @@ -70,11 +70,13 @@ "devDependencies": { "@angular/compiler": "^22.0.0-rc.2", "@angular/compiler-cli": "^22.0.0-rc.2", + "@angular/core": "^22.0.0-rc.2", "@napi-rs/cli": "catalog:", "@oxc-node/cli": "catalog:", "@playwright/test": "^1.58.0", "@types/node": "catalog:", "oxfmt": "catalog:", + "rxjs": "catalog:", "sass": "^1.93.2", "typescript": "catalog:", "vite": "catalog:", diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 32930eec3..69fcec1b9 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -20,7 +20,7 @@ use napi_derive::napi; use oxc_allocator::Allocator; use oxc_angular_compiler::{ AngularVersion as RustAngularVersion, ChangeDetectionStrategy as RustChangeDetectionStrategy, - HmrUpdateModuleOptions, HostMetadataInput as RustHostMetadataInput, + HmrTemplateFields, HmrUpdateModuleOptions, HostMetadataInput as RustHostMetadataInput, TransformOptions as RustTransformOptions, ViewEncapsulation as RustViewEncapsulation, build_ctor_params_metadata_in as core_build_ctor_params_metadata, build_decorator_metadata_array as core_build_decorator_metadata_array, @@ -533,12 +533,23 @@ pub fn compile_template( /// This generates a JavaScript module that can be dynamically imported /// during HMR to update a component's template and styles. /// +/// This knows only what it is given: the generated module replaces `template` +/// and `consts`, but `decls`, `vars` and `ngContentSelectors` stay as the live +/// definition has them, so it is only correct when the new template needs the +/// same ones. For a template that changes them, use `compileForHmrSync`, which +/// compiles the template and writes every field it decides. +/// /// # Arguments /// /// * `component_id` - The component ID (path@ClassName) -/// * `template_js` - The compiled template function as JavaScript +/// * `template_js` - The compiled template function as JavaScript, or an empty +/// string for a styles-only update /// * `styles` - The component's CSS styles, or `None` when unknown. An empty /// array is definitive and emits `styles: []`, clearing the old styles. +/// * `declarations_js` - Optional constant declarations the template references +/// * `consts_js` - The new template's consts array as JavaScript. When +/// `template_js` is given, `None` emits `consts: null`, clearing the old +/// template's consts. /// /// # Returns /// @@ -634,14 +645,18 @@ pub fn compile_for_hmr_sync( let mut all_styles: Vec = styles.unwrap_or_default(); all_styles.extend(output.styles); - // Apply style encapsulation for ViewEncapsulation.Emulated - // Angular uses %COMP% as a placeholder that the runtime replaces with the component ID + // Apply style encapsulation. Angular uses %COMP% as a placeholder + // that the runtime replaces with the component ID. Only Emulated + // encapsulation shims selectors; None and ShadowDom styles are + // served as written (the caller passes the component's + // encapsulation through `options.encapsulation`). + let emulate = opts.encapsulation.unwrap_or_default() == RustViewEncapsulation::Emulated; let encapsulated: Vec = all_styles .iter() .map(|style| { oxc_angular_compiler::styles::finalize_component_style( style, - true, + emulate, "_ngcontent-%COMP%", "_nghost-%COMP%", opts.minify_component_styles, @@ -659,17 +674,30 @@ pub fn compile_for_hmr_sync( None }; - // Generate HMR module with declarations, encapsulated styles, and consts + // Generate the HMR module from everything the new template decided let hmr_module = generate_hmr_update_module(&HmrUpdateModuleOptions { component_id: &component_id, class_name: &component_name, - template_js: Some(&template_js), + template: Some(HmrTemplateFields { + template_js: &template_js, + decls: output.decls, + vars: output.vars, + consts_js: output.consts_js.as_deref(), + ng_content_selectors_js: output.ng_content_selectors_js.as_deref(), + }), styles: encapsulated_styles.as_deref(), declarations_js, - consts_js: output.consts_js.as_deref(), }); - HmrCompileResult { hmr_module, component_id, template_js, errors: vec![] } + HmrCompileResult { + hmr_module, + component_id, + template_js, + // Non-fatal pipeline diagnostics (warnings, advice) — the + // update module was still generated. Errors come back through + // the `Err` arm instead. + errors: OxcError::from_diagnostics(&file_path, &template, output.diagnostics), + } } Err(diagnostics) => HmrCompileResult { hmr_module: String::new(), @@ -1580,6 +1608,10 @@ pub struct ExtractedComponentMetadata { pub template_url: Option, /// Inline styles array. pub styles: Vec, + /// `false` when `styles` could not be statically evaluated (e.g. it + /// references an imported value): `styles` is then empty and must be + /// treated as "unknown", not as "the component has no styles". + pub styles_resolved: bool, /// URLs to external stylesheet files. pub style_urls: Vec, /// Whether this is a standalone component. @@ -1865,6 +1897,7 @@ pub fn extract_component_metadata_sync( .as_ref() .map(std::string::ToString::to_string), styles: metadata.styles.iter().map(std::string::ToString::to_string).collect(), + styles_resolved: metadata.styles_resolved, style_urls: metadata .style_urls .iter() diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 4226accc7..a28390659 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { extractComponentMetadataSync } from '../index.js' +import { extractComponentMetadataSync, transformAngularFileSync } from '../index.js' // The queries reported must be the ones the component is compiled with. describe('extractComponentMetadataSync queries', () => { @@ -169,3 +169,99 @@ export class Widget {} expect(components.map((c) => c.className)).toEqual(['A', 'B']) }) }) + +// The styles reported must be the ones the component is compiled with. +describe('extractComponentMetadataSync styles', () => { + const source = (styles: string) => ` +import { Component } from '@angular/core'; +import { OTHER } from './other'; + +const S = '.a{}'; +const LIST = ['.l{}']; + +@Component({ selector: 'app-x', template: '', styles: ${styles} }) +export class X {} +` + + it.each([ + ['[S]', ['.a{}']], + ['S', ['.a{}']], + ["['.b{}' + '.c{}']", ['.b{}.c{}']], + ['[...LIST]', ['.l{}']], + ['LIST', ['.l{}']], + ['[]', []], + ])('evaluates `styles: %s` as the transform does', (styles, expected) => { + const [component] = extractComponentMetadataSync(source(styles), 'x.component.ts') + expect(component.styles).toEqual(expected) + + const result = transformAngularFileSync(source(styles), 'x.component.ts') + expect(result.errors).toEqual([]) + for (const style of expected) { + expect(result.code).toContain(style.replaceAll('{}', '[_ngcontent-%COMP%]{}')) + } + }) + + it.each(['[OTHER]', '[S, OTHER]', '[S, 1]'])( + 'reports `styles: %s` instead of dropping an entry', + (styles) => { + const code = source(styles) + const [error] = transformAngularFileSync(code, 'x.component.ts').errors + expect(error.message).toMatch( + /^(@Component\.styles depends on 'OTHER'|Failed to resolve styles at position 1 to a string)/, + ) + expect(error.labels.map((label) => code.slice(label.start, label.end))).toEqual([styles]) + expect(extractComponentMetadataSync(code, 'x.component.ts')[0].styles).toEqual([]) + }, + ) +}) + +// `host` is evaluated the way the compiler evaluates it, so a spread or a +// constant reports the same host as the entries written out. +describe('extractComponentMetadataSync host', () => { + const ENTRIES = `'(click)': 'go()', '[title]': 't', '[class.x]': 'on', '[style.width]': 'w', '[attr.aria-label]': 't', role: 'button', class: 'a b', style: 'color: red'` + + function hostOf(preamble: string, host: string) { + const [component] = extractComponentMetadataSync( + `import { Component } from '@angular/core'; +${preamble} +@Component({ selector: 'app-x', template: '', host: ${host} }) +export class X { go() {} t = ''; on = true; w = ''; } +`, + 'x.component.ts', + ) + return component.host + } + + const literal = hostOf('', `{ ${ENTRIES} }`) + + it('reports every kind of entry for a literal host', () => { + expect(literal).toEqual({ + properties: [ + ['[title]', 't'], + ['[class.x]', 'on'], + ['[style.width]', 'w'], + ['[attr.aria-label]', 't'], + ], + attributes: [['role', 'button']], + listeners: [['(click)', 'go()']], + classAttr: 'a b', + styleAttr: 'color: red', + }) + }) + + it('reports the same host for a spread', () => { + expect(hostOf(`const ALL = { ${ENTRIES} };`, '{ ...ALL }')).toEqual(literal) + expect( + hostOf( + `const A = { '(click)': 'go()', '[title]': 't', '[class.x]': 'on', '[style.width]': 'w' }; +const B = { ...A, '[attr.aria-label]': 't', role: 'link' };`, + `{ ...B, role: 'button', class: 'a b', style: 'color: red' }`, + ), + ).toEqual(literal) + }) + + it('reports the same host for a constant', () => { + expect(hostOf(`const HOST = { ${ENTRIES} };`, 'HOST')).toEqual(literal) + expect(hostOf(`const HOST = { ${ENTRIES} } as const;`, 'HOST')).toEqual(literal) + }) +}) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 058c8472e..3df869bec 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -2949,16 +2949,13 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) - it('serves `styles: []` for a class whose `styles` names an array constant', async () => { + it('serves the styles of a class whose `styles` names an array constant', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) const constPath = join(appDir, 'ps457-const.component.ts') - // `collect_string_consts` folds STRING-valued consts only, never an array - // one, so `styles: PS457_STYLES` resolves to nothing. Measured against the - // real compile: this class's `\u0275cmp` carries no `styles` key at all and - // never held `.PS457_CONST_MARKER`. So `styles: []` is the exact answer — - // it clears nothing, because the running component never had these styles. + // `styles` is evaluated statically, so `styles: PS457_STYLES` compiles with + // the constant's styles, and the update carries the same ones. const source = ` import { Component } from '@angular/core'; const PS457_STYLES = ['.PS457_CONST_MARKER { color: red; }']; @@ -2984,10 +2981,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_CONST_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_CONST_MARKER[_ngcontent-%COMP%]') }) - it('serves `styles: []` for an array-constant `styles` beside an empty sibling stylesheet', async () => { + it('serves an array-constant `styles` beside an empty sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2998,10 +2994,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { writeFileSync(fbEmptyCssPath, '') // Two classes. `FbEmptyOwnerComponent` declares `styles: PS457_FB_STYLES`, - // an array constant the compiler does not fold, so it compiles with no - // styles (measured) and `styles: []` is exact. What this pins is that the - // SIBLING has no say: its stylesheet is neither consulted for this class's - // answer nor able to turn it into something else. + // an array constant, and compiles with its styles. What this pins is that + // the SIBLING has no say: its stylesheet is neither consulted for this + // class's answer nor able to turn it into something else. const source = ` import { Component } from '@angular/core'; const PS457_FB_STYLES = ['.PS457_FB_MARKER { color: red; }']; @@ -3033,11 +3028,11 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_FB_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_FB_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) - it('serves `styles: []` for an array-constant `styles` beside a whitespace sibling stylesheet', async () => { + it('serves an array-constant `styles` beside a whitespace sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3078,8 +3073,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_FB_WS_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_FB_WS_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) // A read that FAILED on one stylesheet but succeeded on another is not the @@ -3445,14 +3440,14 @@ describe('@ng/component endpoint resolves the styles per class', () => { // upstream `load` / `transform: { order: 'pre' }` rewrote the module, or // this plugin's own `fileReplacements` pointed `actualId` at another file. // - // For every form the extractor cannot fold, the disk parse yields NO + // For every form the extractor cannot evaluate, the disk parse yields NO // styles — which, taken as definitive, emits `styles: []` and wipes CSS // the component really does have. So an empty answer may only clear when // the stripped disk source matches the stripped source the component // compiled from; otherwise it is unknown and `styles` is omitted. // ---------------------------------------------------------------- - it('keeps the styles of a class whose disk `styles` names an array constant the transform never saw', async () => { + it('serves the styles of a class whose disk `styles` names an array constant the transform never saw', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3460,8 +3455,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { const arrPath = join(appDir, 'ps458-arr.component.ts') writeFileSync(arrHtmlPath, '

    one

    ') - // What is ON DISK: `collect_string_consts` folds string consts only, so - // re-parsing this shape resolves no styles at all. + // What is ON DISK: an array constant, which evaluates to the same styles + // the rewritten code compiled with. const diskSource = ` import { Component } from '@angular/core'; const PS458_ARR = ['.PS458_ARR_MARKER { color: red; }']; @@ -3502,9 +3497,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body, 'an unreadable `styles` must stay unknown, not clear the live CSS').not.toContain( - 'styles:', - ) + expect(body).toContain('.PS458_ARR_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) it('keeps the styles of a class whose disk `styles` names an imported constant', async () => { @@ -3519,8 +3513,12 @@ describe('@ng/component endpoint resolves the styles per class', () => { `export const PS458_IMP = ['.PS458_IMP_MARKER { color: red; }'];\n`, ) - // A cross-file reference is unreadable to the extractor for the same - // reason a same-file array constant is. + // A cross-file reference is unreadable to the extractor, which sees one + // file: the answer is unknown and must not clear the live CSS. The + // transform runs on the source exactly as it sits on disk — Vite hands + // `transform` the file content and resolveImportedValues follows the + // import — so disk source and compiled source are byte-identical and the + // stylesResolved leg, not the strip match, is what keeps the styles. const diskSource = ` import { Component } from '@angular/core'; import { PS458_IMP } from './ps458-imp-styles'; @@ -3533,11 +3531,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { ` writeFileSync(impPath, diskSource) - const transformCode = diskSource.replace( - 'styles: PS458_IMP', - `styles: ['.PS458_IMP_MARKER { color: red; }']`, - ) - const transformed = await transformSource(plugin, transformCode, impPath) + const transformed = await transformSource(plugin, diskSource, impPath) expect(transformed?.code ?? '').toContain('PS458_IMP_MARKER') writeFileSync(impHtmlPath, '

    two

    ') @@ -3559,7 +3553,62 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) - it('keeps the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { + it('keeps the styles of a class mixing imported `styles` with `styleUrls`', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const mixHtmlPath = join(appDir, 'ps458-mix.component.html') + const mixPath = join(appDir, 'ps458-mix.component.ts') + writeFileSync(mixHtmlPath, '

    one

    ') + writeFileSync( + join(appDir, 'ps458-mix-styles.ts'), + `export const PS458_MIX = ['.PS458_MIX_MARKER { color: red; }'];\n`, + ) + writeFileSync(join(appDir, 'ps458-mix.css'), '.PS458_MIX_EXT { color: blue; }\n') + + // The transform resolves the import and compiles BOTH style lists. The + // extractor resolves neither `PS458_MIX` nor the inline styles the import + // holds — merged would be only the external CSS, a partial answer that + // must not be served as the component's styles. + const diskSource = ` + import { Component } from '@angular/core'; + import { PS458_MIX } from './ps458-mix-styles'; + @Component({ + selector: 'app-ps458-mix', + templateUrl: './ps458-mix.component.html', + styles: PS458_MIX, + styleUrls: ['./ps458-mix.css'], + }) + export class MixedStylesComponent {} + ` + writeFileSync(mixPath, diskSource) + + const transformed = await transformSource(plugin, diskSource, mixPath) + expect(transformed?.code ?? '').toContain('PS458_MIX_MARKER') + expect(transformed?.code ?? '').toContain('PS458_MIX_EXT') + + writeFileSync(mixHtmlPath, '

    two

    ') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(mixHtmlPath), + [{ id: normalizePath(mixHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixPath}@MixedStylesComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + // Serving only the external CSS here would silently drop the imported + // inline styles the component was compiled with. + expect(body).not.toContain('styles:') + }) + + it('serves the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3567,9 +3616,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { const catPath = join(appDir, 'ps458-cat.component.ts') writeFileSync(catHtmlPath, '

    one

    ') - // A call expression is unreadable to the extractor AND unreadable to the - // strip (its value has no `[` / quote opener), so the stripped disk form - // keeps the call verbatim while the stripped transform form is `styles: []`. + // A call the strip cannot read (its value has no `[` / quote opener), but + // one that evaluates statically, to the styles the rewritten code has. const diskSource = ` import { Component } from '@angular/core'; const PS458_CAT_BASE = ['.PS458_CAT_MARKER { color: red; }']; @@ -3605,7 +3653,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('styles:') + expect(body).toContain('.PS458_CAT_MARKER[_ngcontent-%COMP%]') + expect(body).toContain('.PS458_CAT_EXTRA[_ngcontent-%COMP%]') }) // The counterweight: when the endpoint IS looking at the source the diff --git a/napi/angular-compiler/test/hmr-update-module.test.ts b/napi/angular-compiler/test/hmr-update-module.test.ts new file mode 100644 index 000000000..cf54fdc0f --- /dev/null +++ b/napi/angular-compiler/test/hmr-update-module.test.ts @@ -0,0 +1,346 @@ +/** + * The HMR update module that `compileForHmrSync` returns, applied to a real + * compiled component with the real `@angular/core`. + * + * Each test compiles a component with this compiler, loads it, and hands the + * update module to Angular's own `ɵɵreplaceMetadata`. Two things must hold for + * the definition that results: + * + * - every field the template decides (`decls`, `vars`, `consts`, + * `ngContentSelectors`) is the new template's, checked against a fresh + * compile of the same component with that template; + * - nothing else changes. `ɵɵdefineComponent` converts `inputs` and `outputs` + * and derives `onPush`, so a definition passed through it a second time + * comes out wrong. + * + * Rendering after a swap is covered by `e2e/tests/hmr-update-definition.spec.ts`. + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import * as core from '@angular/core' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { compileForHmrSync, transformAngularFileSync } from '../index.js' + +const TMP = join(fileURLToPath(new URL('.', import.meta.url)), '.hmr-update-module-tmp') +let nextFile = 0 + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }) + // i18n constants call `$localize` when the definition's `consts` are read. + ;(globalThis as any).$localize ??= (strings: TemplateStringsArray, ...values: unknown[]) => + String.raw({ raw: strings }, ...values) +}) + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }) +}) + +interface ComponentSource { + /** Extra names to import from `@angular/core`. */ + imports?: string + /** Extra `@Component` metadata, e.g. `changeDetection: ...,`. */ + metadata?: string + /** The class body. */ + members?: string +} + +/** Compiles a component named `X` with `template` and loads the class. */ +async function component(template: string, source: ComponentSource = {}): Promise { + const code = `import { Component${source.imports ? `, ${source.imports}` : ''} } from '@angular/core'; + +@Component({ + selector: 'x-cmp', + ${source.metadata ?? ''} + template: ${JSON.stringify(template)}, +}) +export class X { + ${source.members ?? ''} +} +` + const result = transformAngularFileSync(code, '/x/a.ts', {}) + expect(result.errors).toEqual([]) + const file = join(TMP, `component-${nextFile++}.mjs`) + writeFileSync(file, result.code) + return (await import(/* @vite-ignore */ pathToFileURL(file).href)).X +} + +/** Loads an update module the way the browser does: as a module, by its default export. */ +async function loadUpdateModule(code: string): Promise<(...args: unknown[]) => void> { + const file = join(TMP, `update-${nextFile++}.mjs`) + writeFileSync(file, code) + return (await import(/* @vite-ignore */ pathToFileURL(file).href)).default +} + +/** + * A template function's identity for comparison: its source text. A function + * compiled as a nested function expression carries a trailing `;` that a + * declaration does not, and that says nothing about the template. + */ +function templateSource(fn: unknown): string { + return String(fn).replace(/};(\s|$)/g, '}$1') +} + +/** Hot-swaps `template` into `type` the way the runtime does. */ +async function swap(type: any, template: string, id = '/x/a.ts') { + const result = compileForHmrSync(template, type.name, id, null, {}) + expect(result.errors).toEqual([]) + // Seed a stale view so `def.tView` can only end up null if the update + // module wrote `tView: null` — an unrendered component's tView is already + // null, which would let a missing clear slip through. The sentinel is + // never a real view's, so ɵɵreplaceMetadata finds nothing to recreate. + type.ɵcmp.tView ??= {} + const applyMetadata = await loadUpdateModule(result.hmrModule) + ;(core as any).ɵɵreplaceMetadata(type, applyMetadata, [core], []) + // A compiled function's source is what Function#toString returns, so this + // asserts the definition's template is the freshly compiled one, not a + // stale function the spread kept. + expect(templateSource(type.ɵcmp.template)).toBe(templateSource(result.templateJs)) + return result +} + +/** The fields of a definition that its template decides. */ +function templateFields(type: any) { + const def = type.ɵcmp + return { + decls: def.decls, + vars: def.vars, + consts: typeof def.consts === 'function' ? def.consts() : def.consts, + ngContentSelectors: def.ngContentSelectors, + } +} + +/** Every other field of a definition, as a comparable snapshot. */ +function otherFields(type: any) { + const rest = { ...type.ɵcmp } + for (const field of ['decls', 'vars', 'consts', 'ngContentSelectors', 'template', 'tView']) { + delete rest[field] + } + return rest +} + +/** + * Swaps `to` into a component compiled with `from` and checks the result + * against a component compiled with `to` in the first place. + */ +async function expectSwapMatchesFreshCompile( + from: string, + to: string, + source: ComponentSource = {}, +): Promise { + const type = await component(from, source) + const before = otherFields(type) + await swap(type, to) + const fresh = await component(to, source) + expect(templateFields(type)).toEqual(templateFields(fresh)) + expect(otherFields(type)).toEqual(before) + expect(type.ɵcmp.tView).toBeNull() + return type +} + +describe('compileForHmrSync update module', () => { + it('writes the slot counts and projection selectors of the new template', async () => { + const type = await component('', { members: `a = 'A';` }) + const { hmrModule } = await swap( + type, + '{{ a }}', + ) + + expect(templateFields(type)).toMatchObject({ + decls: 4, + vars: 1, + consts: null, + ngContentSelectors: ['[x]'], + }) + // Every constant the module declares is one it uses. + for (const [, name] of hmrModule.matchAll(/const (_c\d+) =/g)) { + expect(hmrModule.split(name).length - 1, `${name} is declared and used`).toBeGreaterThan(1) + } + }) + + describe('slot counts', () => { + const members = { members: `a = 'A'; b = 'B';` } + + it('follows a template that gains an element', async () => { + await expectSwapMatchesFreshCompile('

    one

    ', '

    one

    two

    three', members) + }) + + it('follows a template that gains a binding', async () => { + await expectSwapMatchesFreshCompile( + '

    one

    ', + '

    {{ a }} {{ b }}

    ', + members, + ) + }) + + it('follows a template that loses elements and bindings', async () => { + await expectSwapMatchesFreshCompile( + '

    {{ a }}

    {{ b }}

    ', + '

    one

    ', + members, + ) + }) + + it('follows a template that gains a control-flow block', async () => { + await expectSwapMatchesFreshCompile( + '

    one

    ', + '@if (a) {

    {{ a }}

    } @else { {{ b }} }', + members, + ) + }) + }) + + describe('projection selectors', () => { + it('follows a template that gains an ', async () => { + const type = await expectSwapMatchesFreshCompile( + '

    one

    ', + '', + ) + expect(type.ɵcmp.ngContentSelectors).toEqual(['[x]', '*']) + }) + + it('follows a template that loses its ', async () => { + const type = await expectSwapMatchesFreshCompile('', '

    one

    ') + expect(type.ɵcmp.ngContentSelectors).toBeUndefined() + }) + + it('follows a template that changes its ', async () => { + const type = await expectSwapMatchesFreshCompile( + '', + '', + ) + expect(type.ɵcmp.ngContentSelectors).toEqual(['[y]', 'z-el']) + }) + }) + + describe('consts', () => { + it('follows a template that gains an attribute', async () => { + const type = await expectSwapMatchesFreshCompile( + '

    one

    ', + '

    one

    ', + ) + expect(type.ɵcmp.consts).not.toBeNull() + }) + + it('clears the consts of a template that no longer needs any', async () => { + const type = await expectSwapMatchesFreshCompile('

    one

    ', '

    one

    ') + expect(type.ɵcmp.consts).toBeNull() + }) + + it('follows a template that gains an i18n message', async () => { + const type = await expectSwapMatchesFreshCompile( + '

    one

    ', + '

    Hello

    ', + ) + expect(JSON.stringify(templateFields(type).consts)).toContain('Hello') + }) + }) + + describe('fields the template does not decide', () => { + const io: ComponentSource = { + imports: 'input, model, output', + members: ` + clicked = output(); + renamed = output({ alias: 'renamedAlias' }); + checked = model(false); + label = input('', { alias: 'labelAlias', transform: (v) => String(v).toUpperCase() }); + plain = input(0); + `, + } + + it('leaves outputs as they were, for output(), model() and an alias', async () => { + const type = await component('

    one

    ', io) + const outputs = { ...type.ɵcmp.outputs } + expect(outputs).toEqual({ + clicked: 'clicked', + renamedAlias: 'renamed', + checkedChange: 'checked', + }) + + await swap(type, '

    one

    ') + + expect(type.ɵcmp.outputs).toEqual(outputs) + }) + + it('leaves inputs as they were, for an alias and a transform', async () => { + const type = await component('

    one

    ', io) + const inputs = { ...type.ɵcmp.inputs } + const declaredInputs = { ...type.ɵcmp.declaredInputs } + expect(Object.keys(inputs).sort()).toEqual(['checked', 'labelAlias', 'plain']) + expect(inputs.labelAlias[0]).toBe('label') + + await swap(type, '

    {{ label() }} {{ plain() }}

    ') + + expect(type.ɵcmp.inputs).toEqual(inputs) + expect(type.ɵcmp.declaredInputs).toEqual(declaredInputs) + }) + + it('leaves the change detection strategy as it was', async () => { + for (const strategy of ['Eager', 'OnPush']) { + const type = await component('

    one

    ', { + imports: 'ChangeDetectionStrategy', + metadata: `changeDetection: ChangeDetectionStrategy.${strategy},`, + }) + const onPush = type.ɵcmp.onPush + expect(onPush).toBe(strategy === 'OnPush') + + await swap(type, '

    one

    two

    ') + + expect(type.ɵcmp.onPush, strategy).toBe(onPush) + } + }) + + it('leaves everything else as it was', async () => { + await expectSwapMatchesFreshCompile('

    one

    ', '

    {{ label() }}

    ', { + ...io, + metadata: `host: { '(click)': 'clicked.emit()', '[title]': 'label()', role: 'button' }, exportAs: 'xCmp',`, + }) + }) + }) + + it('does not build the definition again from the live one', async () => { + const type = await component('

    one

    ') + const result = compileForHmrSync('

    two

    ', 'X', '/x/a.ts', null, {}) + const applyMetadata = await loadUpdateModule(result.hmrModule) + let defined = 0 + const namespace = { ...core, ɵɵdefineComponent: () => (defined++, type.ɵcmp) } + + applyMetadata(type, [namespace]) + + expect(defined).toBe(0) + }) + + it('applies two swaps in a row', async () => { + const source: ComponentSource = { + imports: 'model, output', + members: `a = 'A'; clicked = output(); checked = model(false);`, + } + const type = await component('

    one

    ', source) + const outputs = { ...type.ɵcmp.outputs } + const other = otherFields(type) + + await swap(type, '

    {{ a }}

    ') + await swap(type, '{{ a }}') + + const fresh = await component( + '{{ a }}', + source, + ) + expect(templateFields(type)).toEqual(templateFields(fresh)) + expect(type.ɵcmp.outputs).toEqual(outputs) + expect(otherFields(type)).toEqual(other) + }) + + it('handles a component id whose path contains `@`', async () => { + const id = '/repo/node_modules/@scope/pkg/@x/a.ts' + const result = compileForHmrSync('

    one

    ', 'X', id, null, {}) + expect(result.componentId).toBe(`${id}@X`) + expect(result.hmrModule).toContain('export default function X_UpdateMetadata(X, ɵɵnamespaces)') + + const type = await component('') + await swap(type, '

    one

    two

    ', id) + expect(type.ɵcmp.decls).toBe(4) + }) +}) diff --git a/napi/angular-compiler/test/ssr-hmr.test.ts b/napi/angular-compiler/test/ssr-hmr.test.ts index a873f71aa..bdbfd8197 100644 --- a/napi/angular-compiler/test/ssr-hmr.test.ts +++ b/napi/angular-compiler/test/ssr-hmr.test.ts @@ -135,7 +135,10 @@ describe('compileForHmrSync with @ in the file path', () => { }) it('generateHmrModule names the update function after the class', () => { - const hmrModule = generateHmrModule('node_modules/@scope/pkg/src/a.ts@Foo', 'null') + const hmrModule = generateHmrModule( + 'node_modules/@scope/pkg/src/a.ts@Foo', + 'function Foo_Template(rf, ctx) {}', + ) expect(hmrModule).toContain('export default function Foo_UpdateMetadata(Foo, ɵɵnamespaces) {') expect(parseSync('hmr.js', hmrModule).errors).toEqual([]) diff --git a/napi/angular-compiler/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 6d6bba6a9..fff3afb25 100644 --- a/napi/angular-compiler/test/transform.test.ts +++ b/napi/angular-compiler/test/transform.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { transformAngularFile, + transformAngularFileSync, extractComponentUrls, compileTemplate, extractAngularComponentByAst, @@ -611,3 +612,86 @@ describe('object spread in template bindings', () => { expect(result.code).toContain('ctx.b') }) }) + +describe('capitalised element names', () => { + it('compiles as an element instead of dropping the template', async () => { + const source = `import { Component } from '@angular/core'; +@Component({ selector: 'x', template: 'hi' }) +export class X {}` + + const result = await transformAngularFile(source, '/x/a.ts', {}) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(/decls:\s*3/) + expect(result.code).toMatch(/ɵɵelementStart\(0,\s*"View"\)\(1,\s*"text"\)/) + expect(result.code).toMatch(/ɵɵtext\(2,\s*"hi"\)/) + }) + + it('compiles capitalised elements with optional end tags without hanging', async () => { + const source = `import { Component } from '@angular/core'; +@Component({ selector: 'x', template: '
    • a
    • b
    ' }) +export class X {}` + + const result = await transformAngularFile(source, '/x/a.ts', {}) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(/decls:\s*5/) + expect(result.code).toMatch(/ɵɵelementStart\(0,\s*"UL"\)\(1,\s*"LI"\)/) + }) +}) + +describe('host metadata that is not a plain literal', () => { + const listener = + /ɵɵlistener\("press",\s*function X_press_HostBindingHandler\(\)\s*\{\s*return ctx\.go\(\);/ + + it('compiles a spread inside the host object', () => { + const result = transformAngularFileSync( + `import { Component } from '@angular/core'; +const SHARED = { '(press)': 'go()' }; +@Component({ + selector: 'x', + template: '', + host: { ...SHARED, accessibilityRole: 'button' }, +}) +export class X { go() {} }`, + '/x/a.ts', + {}, + ) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(/hostAttrs:\s*\["accessibilityRole",\s*"button"\]/) + expect(result.code).toMatch(listener) + }) + + it('compiles a host that is a constant', () => { + const result = transformAngularFileSync( + `import { Component } from '@angular/core'; +const HOST = { '(press)': 'go()' }; +@Component({ selector: 'x', template: '', host: HOST }) +export class X { go() {} }`, + '/x/a.ts', + {}, + ) + + expect(result.errors).toHaveLength(0) + expect(result.code).toMatch(listener) + }) + + it('reports a host it cannot use, at the host expression', () => { + const source = `import { Directive } from '@angular/core'; +const SHARED = { tabindex: 0 }; +@Directive({ selector: '[x]', host: { ...SHARED, role: 'button' } }) +export class X {}` + + const result = transformAngularFileSync(source, '/x/a.ts', {}) + + expect(result.errors).toHaveLength(1) + const [error] = result.errors + expect(error.message).toBe( + "Decorator host metadata must be a string -> string object, but found unparseable value Value is of type 'number'.", + ) + expect(source.slice(error.labels[0].start, error.labels[0].end)).toBe( + "{ ...SHARED, role: 'button' }", + ) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 10972e2b4..8f8ffe41f 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -792,8 +792,8 @@ export function angular(options: PluginOptions = {}): Plugin[] { // `fileReplacements` pointed `actualId` at a different file. // // On a disk source the compiler never saw, every `styles` - // shape the extractor cannot fold — an array constant, an - // imported one, a `.concat(...)` — resolves to nothing. Read + // shape the extractor cannot evaluate (an imported constant, + // a call to an imported function) resolves to nothing. Read // as definitive that emits `styles: []` and wipes CSS the // running component genuinely has, from a template edit that // never touched the styles at all. @@ -821,16 +821,38 @@ export function angular(options: PluginOptions = {}): Plugin[] { stripComponentMetadata(source, componentDecoratorsByFile.get(fileId)) ) } + // `stylesResolved` comes first, ahead of "serve what was + // read": the extractor here evaluates `styles` without the + // `resolveImportedValues` the transform runs with, so a + // component whose styles live in another file extracts + // `styles: []` — an UNKNOWN answer, not an empty one. Serving + // it as definitive would wipe live CSS that the transform + // compiled from the import, and serving `merged` when it is + // non-empty is no better: it holds only the styleUrls + // content, so the imported inline styles are dropped either + // way. An unknown `styles` field makes the merged array + // itself unrepresentable, so the answer is `null`. const styles: string[] | null = - merged.length > 0 || (external.complete && compiledFromThisSource()) + classMetadata.stylesResolved && + (merged.length > 0 || (external.complete && compiledFromThisSource())) ? merged : null const result = compileForHmrSync(templateContent, className, resolvedId, styles, { angularVersion: pluginOptions.angularVersion, + encapsulation: classMetadata.encapsulation, minifyComponentStyles: getMinifyComponentStyles(), }) + // A failed compile must not be served as an (empty) update + // module — that would call `ɵɵreplaceMetadata` with no + // callback and throw inside the listener. Fall back to the + // `angular:invalidate` full-reload path in the catch below. + const hmrErrors = result.errors.filter((e) => e.severity === 'Error') + if (hmrErrors.length > 0) { + throw new Error(hmrErrors.map((e) => e.message).join('\n')) + } + // Only consume the pending slot once we have real content to // serve. If we deleted unconditionally and the file was // transiently empty (truncate phase of an atomic write on diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ee40e69..4fb27316e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@angular/compiler-cli': specifier: ^22.0.0-rc.2 version: 22.2.1(@angular/compiler@22.2.1)(typescript@6.0.3) + '@angular/core': + specifier: ^22.0.0-rc.2 + version: 22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)(zone.js@0.16.3) '@napi-rs/cli': specifier: 'catalog:' version: 3.10.7(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.1.0)(supports-color@8.1.1) @@ -90,6 +93,9 @@ importers: oxfmt: specifier: 'catalog:' version: 0.72.0 + rxjs: + specifier: 'catalog:' + version: 7.8.2 sass: specifier: ^1.93.2 version: 1.105.1