From 7168b525b88611852daafb6ee6aff502769d28ab Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 2 Oct 2026 11:30:19 +0100 Subject: [PATCH 1/2] fix(linker): accept template literals in styles and host bindings Partial declarations whose strings were rewritten as template literals (for example by esbuild, which prefers backticks for strings containing quotes or newlines) linked with their styles and host bindings missing. Emptied styles also downgraded encapsulation from Emulated to None. Treat a template literal with no substitutions as its cooked string value for `styles` entries and host `properties`/`listeners`, sharing the existing handling used by `get_string_property`. --- crates/oxc_angular_compiler/src/linker/mod.rs | 54 ++++++++----------- .../oxc_angular_compiler/tests/linker_test.rs | 42 +++++++++++++++ 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 1e413b990..3a1ff4213 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -417,29 +417,28 @@ fn get_metadata_object<'a>(call: &'a CallExpression<'a>) -> Option<&'a ObjectExp }) } -/// Extract a string property value from an object expression. +/// Extract a static string from an expression. /// Handles both regular string literals (`"..."`) and template literals with no expressions (`` `...` ``). +fn as_static_string<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { + match expr { + Expression::StringLiteral(lit) => Some(lit.value.as_str()), + Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => { + tl.quasis.first()?.value.cooked.as_deref() + } + _ => None, + } +} + +/// Extract a string property value from an object expression. fn get_string_property<'a>(obj: &'a ObjectExpression<'a>, name: &str) -> Option<&'a str> { - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop - && matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) + obj.properties.iter().find_map(|prop| match prop { + ObjectPropertyKind::ObjectProperty(prop) + if matches!(&prop.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) => { - match &prop.value { - Expression::StringLiteral(lit) => { - return Some(lit.value.as_str()); - } - Expression::TemplateLiteral(tl) if tl.expressions.is_empty() => { - if let Some(quasi) = tl.quasis.first() - && let Some(cooked) = &quasi.value.cooked - { - return Some(cooked.as_str()); - } - } - _ => {} - } + as_static_string(&prop.value) } - } - None + _ => None, + }) } /// Extract the source text of a property value from an object expression. @@ -1557,11 +1556,8 @@ fn extract_host_metadata_input( PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value = match &p.value { - Expression::StringLiteral(s) => s.value.to_string(), - _ => continue, - }; - input.properties.push((key, value)); + let Some(value) = as_static_string(&p.value) else { continue }; + input.properties.push((key, value.to_string())); } } @@ -1573,11 +1569,8 @@ fn extract_host_metadata_input( PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value = match &p.value { - Expression::StringLiteral(s) => s.value.to_string(), - _ => continue, - }; - input.listeners.push((key, value)); + let Some(value) = as_static_string(&p.value) else { continue }; + input.listeners.push((key, value.to_string())); } } @@ -2040,8 +2033,7 @@ fn link_component( ArrayExpressionElement::SpreadElement(_) => continue, _ => el.to_expression(), }; - if let Expression::StringLiteral(s) = expr { - let style = s.value.as_str(); + if let Some(style) = as_static_string(expr) { if is_emulated { let scoped = crate::styles::shim_css_text(style, "_ngcontent-%COMP%", "_nghost-%COMP%"); diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 129eb40e4..cbd0ad332 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -121,3 +121,45 @@ MyControl.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21 let result = link(&allocator, code, "test.mjs"); insta::assert_snapshot!(result.code); } + +/// Helper to build a ɵɵngDeclareComponent source with a given `styles` array. +fn make_component_source_with_styles(styles: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class MyCmp {{}} +MyCmp.ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "17.0.0", type: MyCmp, selector: "my-cmp", template: "
", styles: [{styles}] }});"# + ) +} + +#[test] +fn test_link_styles_template_literal_matches_string_literal() { + for (template_styles, string_styles) in [ + ("`.a { color: red }`", r#"".a { color: red }""#), + (r#"`.a { content: "\\f101\n" }`"#, r#"".a { content: \"\\f101\n\" }""#), + ] { + let allocator = Allocator::default(); + let template = + link(&allocator, &make_component_source_with_styles(template_styles), "test.mjs"); + let string = + link(&allocator, &make_component_source_with_styles(string_styles), "test.mjs"); + assert!(template.code.contains("styles: ["), "styles dropped:\n{}", template.code); + assert_eq!(template.code, string.code); + } +} + +#[test] +fn test_link_host_bindings_template_literal_matches_string_literal() { + let make = |prop: &str, listener: &str| { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ properties: {{ "id": {prop} }}, listeners: {{ "click": {listener} }} }} }});"# + ) + }; + let allocator = Allocator::default(); + let template = link(&allocator, &make("`this.dirId`", "`onClick($event)`"), "test.mjs"); + let string = link(&allocator, &make(r#""this.dirId""#, r#""onClick($event)""#), "test.mjs"); + assert!(template.code.contains("dirId"), "host property dropped:\n{}", template.code); + assert!(template.code.contains("onClick"), "host listener dropped:\n{}", template.code); + assert_eq!(template.code, string.code); +} From 5a79f59a66fc3a127b8f6124206e8bc669cb620b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 3 Oct 2026 00:12:08 +0800 Subject: [PATCH 2/2] fix(linker): cover remaining string metadata rewritten as template literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same bundler rewrite (strings → no-substitution template literals) still leaked through other linker paths: - `inputs: { x: [`pub`, `prop`] }` failed the first-is-string check, so the `[InputFlags, ...]` definition-format element was never inserted. The runtime reads element 0 as InputFlags, so a truthy publicName was treated as SignalBased and the alias was lost. - `deps: `invalid`` was not recognised, producing a plain `new Type()` factory instead of `ɵɵinvalidFactory()`. - `host.attributes` values emitted the raw source (`` `x` `` rather than `"x"`), diverging from the quoted form; attr keys and input public/class names are now emitted through escape_string. - The styles loop used `to_expression()`, which panics on array elisions; `as_expression()` skips them instead. --- crates/oxc_angular_compiler/src/linker/mod.rs | 74 +++++++++------ .../oxc_angular_compiler/tests/linker_test.rs | 89 +++++++++++++++++++ 2 files changed, 136 insertions(+), 27 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 3a1ff4213..dd1785b63 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -625,14 +625,13 @@ fn is_property_false(obj: &ObjectExpression<'_>, name: &str) -> bool { }) } -/// Check if a property exists and its value is a specific string literal. +/// Check if a property exists and its value is a specific static string +/// (string literal or template literal with no substitutions). fn is_property_string(obj: &ObjectExpression<'_>, name: &str, value: &str) -> bool { obj.properties.iter().any(|prop| { - matches!(prop, - ObjectPropertyKind::ObjectProperty(p) - if matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) - && matches!(&p.value, Expression::StringLiteral(s) if s.value == value) - ) + let ObjectPropertyKind::ObjectProperty(p) = prop else { return false }; + matches!(&p.key, PropertyKey::StaticIdentifier(ident) if ident.name == name) + && as_static_string(&p.value) == Some(value) }) } @@ -848,10 +847,17 @@ fn build_host_attrs(host_obj: &ObjectExpression<'_>, source: &str) -> String { PropertyKey::StringLiteral(s) => s.value.to_string(), _ => continue, }; - let value_span = p.value.span(); - let value = &source[value_span.start as usize..value_span.end as usize]; - attrs.push(format!("\"{key}\"")); - attrs.push(value.to_string()); + // Static strings (including template literals a bundler may have + // emitted) are emitted cooked and quoted like Angular's output. + let value = as_static_string(&p.value).map_or_else( + || { + let span = p.value.span(); + source[span.start as usize..span.end as usize].to_string() + }, + |s| crate::output::emitter::escape_string(s, false), + ); + attrs.push(crate::output::emitter::escape_string(&key, false)); + attrs.push(value); } } } @@ -1344,17 +1350,27 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source let quoted_key = quote_key(&key); + // Simple string: propertyName: "publicName" → keep as is. The value may + // arrive as a template literal when a bundler rewrote the string. + if let Some(public_name) = as_static_string(&p.value) { + entries.push(format!( + "{quoted_key}: {}", + crate::output::emitter::escape_string(public_name, false) + )); + continue; + } + match &p.value { - // Simple string: propertyName: "publicName" → keep as is - Expression::StringLiteral(lit) => { - entries.push(format!("{quoted_key}: \"{}\"", lit.value.as_str())); - } // Array: check if it's declaration format [publicName, classPropertyName] // and convert to definition format [InputFlags, publicName, classPropertyName] Expression::ArrayExpression(arr) => { let val = &source[p.value.span().start as usize..p.value.span().end as usize]; - let first_is_string = - matches!(arr.elements.first(), Some(ArrayExpressionElement::StringLiteral(_))); + let first_is_string = arr + .elements + .first() + .and_then(ArrayExpressionElement::as_expression) + .and_then(as_static_string) + .is_some(); if !first_is_string { // Already in definition format or unknown, keep as is. @@ -1377,8 +1393,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source } // Object: Angular 16+ format with classPropertyName, publicName, isRequired, etc. Expression::ObjectExpression(obj) => { - let public_name = get_string_property(obj, "publicName").unwrap_or(&key); - let declared_name = get_string_property(obj, "classPropertyName").unwrap_or(&key); + let public_name = crate::output::emitter::escape_string( + get_string_property(obj, "publicName").unwrap_or(&key), + false, + ); + let declared_name = crate::output::emitter::escape_string( + get_string_property(obj, "classPropertyName").unwrap_or(&key), + false, + ); let is_signal = get_bool_property(obj, "isSignal").unwrap_or(false); let is_required = get_bool_property(obj, "isRequired").unwrap_or(false); // Angular emits `transformFunction: null` for signal inputs without @@ -1399,15 +1421,14 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source if flags == 0 && transform.is_none() && public_name == declared_name { // Simple case: no flags, no transform, names match - entries.push(format!("{quoted_key}: \"{public_name}\"")); + entries.push(format!("{quoted_key}: {public_name}")); } else if let Some(transform_fn) = transform { entries.push(format!( - "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\", {transform_fn}]" + "{quoted_key}: [{flags}, {public_name}, {declared_name}, {transform_fn}]" )); } else { - entries.push(format!( - "{quoted_key}: [{flags}, \"{public_name}\", \"{declared_name}\"]" - )); + entries + .push(format!("{quoted_key}: [{flags}, {public_name}, {declared_name}]")); } } // Unknown format, keep as is @@ -2029,10 +2050,9 @@ fn link_component( if let Some(styles_arr) = get_array_property(meta, "styles") { let mut scoped_styles: Vec = Vec::new(); for el in &styles_arr.elements { - let expr = match el { - ArrayExpressionElement::SpreadElement(_) => continue, - _ => el.to_expression(), - }; + // `as_expression` returns None for spreads and elisions (e.g. `[, "a"]`), + // where `to_expression` would panic. + let Some(expr) = el.as_expression() else { continue }; if let Some(style) = as_static_string(expr) { if is_emulated { let scoped = diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index cbd0ad332..e408eac1e 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -163,3 +163,92 @@ MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0. assert!(template.code.contains("onClick"), "host listener dropped:\n{}", template.code); assert_eq!(template.code, string.code); } + +/// Static `host.attributes` values rewritten as template literals keep their +/// cooked value and emit byte-identical hostAttrs to the quoted form. +#[test] +fn test_link_host_attributes_template_literal_matches_string_literal() { + let make = |value: &str| { + format!( + r#"import * as i0 from "@angular/core"; +export class MyDir {{}} +MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0.0", type: MyDir, selector: "[myDir]", host: {{ attributes: {{ "aria-label": {value} }} }} }});"# + ) + }; + let allocator = Allocator::default(); + let template = link(&allocator, &make(r#"`say "hi"`"#), "test.mjs"); + let string = link(&allocator, &make(r#""say \"hi\"""#), "test.mjs"); + assert!( + template.code.contains(r#"hostAttrs: ["aria-label", "say \"hi\""]"#), + "host attribute dropped or unescaped:\n{}", + template.code + ); + assert_eq!(template.code, string.code); +} + +/// `inputs` values rewritten as template literals are treated like string +/// literals. For the aliased array form `[publicName, classPropertyName]` this +/// preserves the declaration-format detection — otherwise the array would be +/// passed through verbatim and the runtime would read the public name as +/// `InputFlags`. Array elements keep their original source spelling, so only +/// the simple and object forms are asserted byte-identical. +#[test] +fn test_link_inputs_template_literal_matches_string_literal() { + // Simple value form: same emitted output either way. + let allocator = Allocator::default(); + let template = link(&allocator, &make_directive_source(r#"prop: `publicProp`"#), "test.mjs"); + let string = link(&allocator, &make_directive_source(r#"prop: "publicProp""#), "test.mjs"); + assert_eq!(template.code, string.code); + + // Aliased array form: the first template-literal element must be recognised + // as a string so the [0, ...] flags element is inserted. + let allocator = Allocator::default(); + let template = + link(&allocator, &make_directive_source(r#"prop: [`publicProp`, `prop`]"#), "test.mjs"); + assert!( + template.code.contains(r#"prop: [0, `publicProp`, `prop`]"#), + "aliased input lost its InputFlags element:\n{}", + template.code + ); + let string = + link(&allocator, &make_directive_source(r#"prop: ["publicProp", "prop"]"#), "test.mjs"); + assert!( + string.code.contains(r#"prop: [0, "publicProp", "prop"]"#), + "quoted input changed behaviour:\n{}", + string.code + ); + + // Object format: publicName/classPropertyName may also be template literals. + let allocator = Allocator::default(); + let template = link( + &allocator, + &make_directive_source( + r#"prop: { classPropertyName: `prop`, publicName: `publicProp`, isRequired: false, isSignal: false }"#, + ), + "test.mjs", + ); + let string = link( + &allocator, + &make_directive_source( + r#"prop: { classPropertyName: "prop", publicName: "publicProp", isRequired: false, isSignal: false }"#, + ), + "test.mjs", + ); + assert_eq!(template.code, string.code); +} + +/// `deps: "invalid"` rewritten as a template literal still selects the +/// ɵɵinvalidFactory path instead of falling through to `new Type()`. +#[test] +fn test_link_factory_invalid_deps_template_literal() { + let allocator = Allocator::default(); + let code = r#" +import * as i0 from "@angular/core"; +class MyService { +} +MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: MyService, deps: `invalid`, target: i0.ɵɵFactoryTarget.Injectable }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(result.code.contains("ɵɵinvalidFactory"), "deps not invalid:\n{}", result.code); +}