From 22fb4b9f7ed89f60e051040c2ac80856ca3b5a94 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Thu, 8 Oct 2026 22:07:38 +0100 Subject: [PATCH 1/8] fix(directive): evaluate `host` metadata statically like ngtsc A `host` that is not a plain object literal of string values was silently dropped: const SHARED = { '(press)': 'go()' }; @Component({ host: { ...SHARED, accessibilityRole: 'button' } }) kept the attribute and lost the listener, and `host: HOST` compiled to no host bindings at all. Neither reported an error. The component and directive extractors each read `host` by walking an `ObjectExpression`'s own properties, skipping spreads and returning nothing for any other expression. ngtsc evaluates the expression with its partial evaluator (`evaluateHostExpressionBindings`). The evaluator that models it here already serves `inputs:`, `outputs:` and `queries:`; `host` now goes through it too, from one shared `evaluate_host_metadata`. - A spread, a constant (`const`, `let`, `as const`), a member access, a same-file function call and a computed key all resolve, and compile to the same bindings as the entries written out. As in a `Map`, a repeated key takes the later value and keeps the first position. - A host that cannot be used is reported on the host expression, with ngtsc's message: `Decorator host metadata must be an object`, or `... must be a string -> string object, but found unparseable value`, each with the line describing the value, and `Property/Event/Class/Style binding must be string` for a binding whose value is dynamic. The error joins the `decorator_io_errors` chain after `selector`, which is where ngtsc checks it. - An import is read when `resolve_imported_values` is on, as for the other fields; otherwise the existing "imported from another module" error says why. One case does not match ngtsc: it emits a plain attribute's dynamic value as an expression (`hostAttrs: ["role", make()]`). Host attributes are strings here, so that is an error rather than a drop. `host_attribute_unknown_identifier_dropped` pinned the silent drop for an unresolvable computed key; it now expects ngtsc's error. Verified against @angular/compiler-cli 22.2.1: 15 compare fixtures, and 83 cases diffed against its output and diagnostics. --- .../src/component/decorator.rs | 71 +-- .../src/directive/decorator.rs | 181 ++++++-- .../oxc_angular_compiler/src/directive/mod.rs | 7 +- .../tests/cross_file_values_test.rs | 55 +++ .../tests/host_metadata_evaluation_test.rs | 420 ++++++++++++++++++ .../tests/integration_test.rs | 17 +- .../host-spread-and-constant.fixture.ts | 149 +++++++ .../test/extract-component-metadata.test.ts | 51 +++ napi/angular-compiler/test/transform.test.ts | 57 +++ 9 files changed, 896 insertions(+), 112 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs create mode 100644 napi/angular-compiler/e2e/compare/fixtures/host-bindings/host-spread-and-constant.fixture.ts diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index c797893ff..93c2f6e90 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; @@ -480,66 +481,28 @@ 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) } diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 3123ac6cc..0e45f2055 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -1096,11 +1096,11 @@ pub(crate) fn angular_decorator_config<'a>( Some((config, name)) } -/// The first error ngtsc raises for the inputs, outputs, queries and selector -/// of a `@Component` / `@Directive` on `class`, in the order it checks them +/// The first error ngtsc raises for the inputs, outputs, queries, selector and +/// host of a `@Component` / `@Directive` on `class`, in the order it checks them /// (`extractDirectiveMetadata`): `inputs:`, `@Input` members, `outputs:`, /// output members, query members (`@ViewChild`, ...), then `queries:`, then -/// `selector`. ngtsc stops at the first one. +/// `selector`, then `host:`. ngtsc stops at the first one. /// /// Each error points where ngtsc's does: the `inputs:` / `outputs:` value, the /// member, or the part of a query at fault. `source_text` is the file's, as @@ -1432,9 +1432,10 @@ pub fn decorator_io_errors<'a>( ); queries.error.map(at) }; - // The selector is the last thing `extractDirectiveMetadata` reads. let selector = || directive_selector_error(config, decorator_name, &class_name, &evaluator, consts); + // `extractHostBindings` runs right after the selector check. + let host = || host_metadata_error(allocator, config, decorator_name, consts).map(at); io.as_ref() .and_then(|io| io.input_error.clone()) @@ -1445,6 +1446,7 @@ pub fn decorator_io_errors<'a>( .or_else(member_queries) .or_else(queries) .or_else(selector) + .or_else(host) .map(|(message, span, related)| { let diagnostic = OxcDiagnostic::error(message).with_label(span); match related { @@ -1780,58 +1782,147 @@ pub(crate) fn initializer_api( named(&required.object).or_else(|| namespaced(&required.object)).map(|api| (api, true)) } -/// Extract host metadata from a host object expression. +/// What a `host` key binds, as Angular's `parseHostBindings` classifies it. +pub(crate) enum HostKey { + /// `[property]`, `[class.x]`, `[style.x]`, `[attr.x]` + Property, + /// `(event)` + Listener, + /// `class` + Class, + /// `style` + Style, + /// Any other key: a static attribute. + Attribute, +} + +impl HostKey { + pub(crate) fn of(key: &str) -> Self { + if key.starts_with('[') && key.ends_with(']') { + Self::Property + } else if key.starts_with('(') && key.ends_with(')') { + Self::Listener + } else { + match key { + "class" => Self::Class, + "style" => Self::Style, + _ => Self::Attribute, + } + } + } +} + +/// The entries of the `host` metadata `expr` evaluates to, in order, or the error ngtsc +/// reports for it (on `expr`). /// -/// Reference: packages/compiler/src/render3/view/compiler.ts:560-604 -fn extract_host_metadata<'a>( +/// Like ngtsc, the expression is evaluated statically rather than read as a literal, so +/// a spread (`{ ...SHARED, role: 'x' }`) or a constant (`host: HOST`) gives the same +/// entries as writing them out. As in a `Map`, an entry that repeats a key replaces its +/// value and keeps the first one's position. +/// +/// `decorator_name` is `Component` or `Directive`. +/// +/// Ported from ngtsc's `evaluateHostExpressionBindings` +/// (packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts). +pub(crate) fn evaluate_host_metadata<'a>( allocator: &'a Allocator, - expr: &Expression<'a>, + expr: &'a Expression<'a>, + decorator_name: &str, consts: &StringConsts<'a>, -) -> Option> { - let Expression::ObjectExpression(obj) = expr else { - return None; +) -> Result, Ident<'a>)>, String> { + let subject = format!("@{decorator_name}.host"); + let host = Evaluator::new(consts).evaluate(expr); + let Value::Object(props) = &host else { + return Err(value_error( + &subject, + || "Decorator host metadata must be an object".to_string(), + &host, + )); }; - let mut host = R3HostMetadata::new(allocator); + let alloc = |s: &str| Ident::from(allocator.alloc_str(s)); + let mut entries = std::vec::Vec::<(Ident<'a>, Ident<'a>)>::with_capacity(props.len()); + for prop in props { + if entries.iter().any(|(key, _)| key.as_str() == prop.key) { + continue; + } + // The value the key ends up with: its last one. + let value = host.prop(&prop.key).map_or(&prop.value, |p| &p.value); + let value = match value { + Value::Enum { value, .. } => value, + value => value, + }; + match value { + Value::String(s) => entries.push((alloc(&prop.key), alloc(s))), + // ngtsc's `DynamicValue`. `parseHostBindings` rejects it for everything but + // a plain attribute, whose expression it emits as it is written. + _ if value.is_dynamic() || matches!(value, Value::Function(_)) => { + return Err(match HostKey::of(&prop.key) { + HostKey::Property => "Property binding must be string".to_string(), + HostKey::Listener => "Event binding must be string".to_string(), + HostKey::Class => "Class binding must be string".to_string(), + HostKey::Style => "Style binding must be string".to_string(), + HostKey::Attribute => format!( + "Decorator host metadata must be a string -> string object, but the \ + value of '{}' could not be determined statically. OXC does not \ + support dynamic host attribute values.", + prop.key + ), + }); + } + _ => { + return Err(value_error( + &subject, + || { + "Decorator host metadata must be a string -> string object, but found \ + unparseable value" + .to_string() + }, + value, + )); + } + } + } + Ok(entries) +} - 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) = extract_string_value(allocator, &prop.value, consts) else { - continue; - }; +/// The error ngtsc reports for the `host` of a `@Component` / `@Directive` whose +/// metadata is `config`, and the node it reports it on (the `host` value). +fn host_metadata_error<'a>( + allocator: &'a Allocator, + config: Option<&'a ObjectExpression<'a>>, + decorator_name: &str, + consts: &StringConsts<'a>, +) -> Option<(String, Span)> { + let expr = config_property(config?, "host", consts)?; + evaluate_host_metadata(allocator, expr, decorator_name, consts) + .err() + .map(|message| (message, expr.span())) +} - let key_str = key_name.as_str(); +/// 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: &'a Expression<'a>, + consts: &StringConsts<'a>, +) -> Option> { + // An unusable host is reported by `decorator_io_errors`. + let entries = evaluate_host_metadata(allocator, expr, "Directive", consts).ok()?; - 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) - match key_str { - "class" => { - host.class_attr = Some(value); - } - "style" => { - host.style_attr = Some(value); - } - _ => { - // Regular static attribute - convert to OutputExpression - let attr_expr = OutputAstBuilder::string(allocator, value); - host.attributes.push((key_name, attr_expr)); - } - } + let mut host = R3HostMetadata::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, OutputAstBuilder::string(allocator, value))); } } } - Some(host) } diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 761cae123..5a15e2627 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -27,6 +27,10 @@ pub use compiler::{ create_inputs_literal, create_outputs_literal, }; pub(crate) use decorator::find_directive_decorator; +pub(crate) use decorator::{ + HostKey, angular_decorator_config, evaluate_host_metadata, extract_string_value, + resolve_template_literal, +}; pub(crate) use decorator::{ INPUT_API, InitializerApi, MODEL_API, OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API, QUERY_APIS, initializer_api, initializer_api_call, @@ -35,9 +39,6 @@ pub use decorator::{ StringConsts, collect_string_consts, decorator_io_errors, extract_directive_metadata, find_directive_decorator_span, param_decorator_errors, }; -pub(crate) use decorator::{ - angular_decorator_config, extract_string_value, resolve_template_literal, -}; pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub(crate) use dts_type::quote as ts_string_literal; diff --git a/crates/oxc_angular_compiler/tests/cross_file_values_test.rs b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs index 8a950159a..470d27d93 100644 --- a/crates/oxc_angular_compiler/tests/cross_file_values_test.rs +++ b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs @@ -237,3 +237,58 @@ export class A {} error_messages(&result) ); } + +/// `host` goes through the same evaluator, so an imported object or string resolves +/// in a spread, as the whole host and as an entry's value. +#[test] +fn imported_host_metadata_evaluates() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "app/meta.ts", + "export const SHARED = { '(press)': 'go()' };\nexport const NAME = 'button';", + ); + let listener = + r#"i0.ɵɵlistener("press",functionA_press_HostBindingHandler(){returnctx.go();})"#; + for (host, attrs) in + [("{ ...SHARED, role: NAME }", Some(r#"hostAttrs:["role","button"]"#)), ("SHARED", None)] + { + let source = format!( + "import {{ Directive }} from '@angular/core'; +import {{ SHARED, NAME }} from './meta'; +@Directive({{ selector: '[a]', host: {host} }}) +export class A {{ go() {{}} }} +" + ); + let result = transform(&dir, &source, &resolve_options()); + assert!(!result.has_errors(), "{}", error_messages(&result)); + let compact: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(listener), "`host: {host}`:\n{compact}"); + if let Some(attrs) = attrs { + assert!(compact.contains(attrs), "`host: {host}`:\n{compact}"); + } + } +} + +/// Without the option the import is not read, and the host is reported, not dropped. +#[test] +fn imported_host_metadata_without_the_option_is_reported() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const SHARED = { '(press)': 'go()' };"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { SHARED } from './meta'; +@Directive({ selector: '[a]', host: { ...SHARED } }) +export class A { go() {} } +"#, + &TransformOptions::default(), + ); + assert!( + error_messages(&result).contains( + "@Directive.host depends on 'SHARED', which is imported from another module." + ), + "{}", + error_messages(&result) + ); +} 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..bc170450f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs @@ -0,0 +1,420 @@ +//! `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}`"); + } + } +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 7738daf01..ea7e9c361 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -12139,10 +12139,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 +12154,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/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/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 4226accc7..424d85d6f 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -169,3 +169,54 @@ export class Widget {} expect(components.map((c) => c.className)).toEqual(['A', 'B']) }) }) + +// `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/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 6d6bba6a9..4ea274731 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,59 @@ describe('object spread in template bindings', () => { expect(result.code).toContain('ctx.b') }) }) + +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' }", + ) + }) +}) From 029d6f235f1a52a390379fc9eadb3e293b493ea0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 10:19:01 +0800 Subject: [PATCH 2/8] fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types The dts type printer dropped every comment; ngtsc's path through TypeScript's emitter (`onlyPrintJsDocStyle`) keeps `/**`-style and `/*!` comments on a node's leading/trailing edges, plus every comment the intervening list scans collect. Rewrite the printer to port the emitter's comment paths verbatim: `emitNodeList`/`emitNodeListItems` list handling with `ListFormat` flags, `emitTokenWithComment`, the `containerPos`/`containerEnd` dedup in `emitCommentsBeforeNode`/`emitCommentsAfterNode`, `iterateCommentRanges` (including U+2028/U+2029 counting as line breaks while the scan reads past them), and `writeCommentRange` re-indenting. This also reproduces the quirks: a comment scanned by two enclosing lists prints twice, and a comment after a named tuple member's `?` is dropped because the colon's position scan starts after it. Verified against ngtsc 22.1.7: the `COMMENTS_NOT_KEPT` fixture becomes `COMMENTS_KEPT` and gains the remaining issue cases (`string /** doc */`, `string | number /** doc */`, a leading `/** doc */` on its own line, a multiline `/* */` inside type arguments, and the doubled `Array<\u2028` comment). Closes #506 * fix(dts): comment-scan edges in the dts type printer Follow-ups from review of the emitter port: - `token_end_before` misread `//` inside strings/comments and paired a trailing `*/` with the last `/*` instead of the first unpaired one, corrupting output like `{ a: 'x//y' ...}` and `/** a /** b */`. Both scans are now string/comment aware. - Type-reference names are synthesized nodes upstream (pos/end -1), so they keep no comments: `Signal /** j */ ` prints `i0.Signal`. - A tuple's `]` scans from the element list's `end`, past a trailing comma; mapped-type `±readonly`/`±?` are two tokens each; the `...` of a binding rest and `${` of a template literal are emitted tokens; a `typeof` entity name is emitted part by part. Each picks up the comments TypeScript's own scans keep. - `isWhiteSpaceSingleLine` is TypeScript's exact codepoint list (adds NEL, ZWSP and friends), and `ListFormat.Indented` applies unconditionally, indenting index-signature parameters' comments one extra level. Also documents the known multi-line gap: ngtsc's `disposeEmitNodes` clears `SingleLine` marks on earlier-emitted fields, so bare tuple/type literal/mapped types can print multi-line there. That needs the field-order mechanism and predates the comment port. * fix(dts): one write per template quasi; `//` inside `/* */` is comment text - TSTemplateLiteralType emits each quasi chunk (`` `text${` ``, `}text${`, `}text``) as a single write like TypeScript's emitLiteral, so a line break inside the text never gets re-indented: `` `a\n${string}` `` prints `${` at column 0. The chunk stays inside emit_node, so the head's trailing `/** j */` scan still fires. - line_comment_start now scans the whole text with string/block-comment state, so a `//` on the continuation line of a `/* */` no longer truncates the line: `string | /* a\n // b */\n number` keeps the comment like ngtsc. * fix(dts): real elision positions and trailing commas in binding lists - An OmittedExpression is zero-width right after its preceding comma, not at prev_end + 1 (which landed inside the previous element's trivia). Track a cursor past each element's terminating comma instead. - Both binding list formats carry AllowTrailingComma: `[a, ,]` and `{a,}` keep their trailing comma (emitTokenWithComment on the last element — an elision suppresses the comma's leading scan since pos == startPos — plus the comma's own same-line trailing scan, and the closing scan then runs at the list's end). * fix(dts): `${ }` holes in a template lex as code in the trivia scan A backward trivia scan that starts inside a template placeholder saw the opening backtick as an unterminated string and gave up, so `` `${\n/** doc */\nstring}` `` lost the JSDoc that ngtsc keeps. Template text is now skipped as text while `${ }` regions are lexed as code — comments inside them count, braces track `{ T: U }` members, and nested templates recurse — in both `line_comment_start` and `block_comment_start`. * fix(dts): lex the source's comments once; skip regex literals Two changes in the backward trivia scan's lexer: - `lex_comments` builds the file's `//`/`/* */` table in one pass and `pos_of` queries it, instead of rescanning the whole prefix per node. - The lexer skips regex literals (operand heuristic + `[...]`/escape aware), so a `/[/*]/` earlier in the file can't pair its `/*` with a comment's `*/` inside the type and drop the comment ngtsc keeps. * fix(dts): hole scan resume, keyword regexes, TS trivia trim - `template_end` resumed past `hole_end`'s return plus one more byte, skipping the closing backtick of `` `${string}` `` and swallowing the rest of the file as template text. - A `/` after a keyword (`return`, `throw`, `case`, `typeof`, …) opens a regex — the operand heuristic only saw the identifier's last byte and called it division, so `return /[/*]/` leaked a `/*` into the comment table. - `token_end_before` trims trivia with `is_whitespace_like` (`isWhiteSpaceSingleLine` + line breaks) instead of `char::is_whitespace`: U+200B/U+FEFF count as trivia and U+2028/9 stay line breaks. * fix(dts): build the comment table from the parser's own comments The hand-rolled lexer was chasing lexing edge cases (`\`` escapes, regexes after `return`, regexes as statement bodies — `if (ok) /re/.test(x)`) and re-lexed the whole file for every printed type. `program.comments` already has the file's comment ranges — lexed correctly once — so `Lexed` is now built from it and shared across a class's printers via `Rc`, deleting the lexer (and its regex/keyword/prev-token machinery) outright. * fix(dts): binary-search the comment table in the trivia scan The sorted, disjoint comment ranges admit a partition_point lookup: the only candidate covering a window end is the last range starting before it — O(log n) instead of a linear scan per pos_of call. * fix(dts): signed mapped modifiers emit only the sign token `readonlyToken`/`questionToken` are composite: `emit` prints the sign and `writeKeyword`/`writePunctuation` prints `readonly`/`?` with no comment scan, so a comment after a line break between them is dropped upstream. Wrapping the keyword in emit_node preserved it. Same-line comments still print as the sign's own trailing comments. --- .../src/component/transform.rs | 2 + .../src/directive/dts_type.rs | 2280 +++++++++++++---- .../src/directive/evaluator.rs | 6 + .../tests/dts_input_transform_type_test.rs | 130 +- 4 files changed, 1899 insertions(+), 519 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 934427735..8637eee4a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3725,6 +3725,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 +3876,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( diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 2d233d75f..1f0539bec 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -9,20 +9,99 @@ //! Every part of the type is printed from the AST, never copied from the //! source, so no position can keep a name ngtsc would have rewritten. A form //! this printer doesn't cover makes the whole type `unknown`. +//! +//! Comments follow TypeScript's declaration emit (`onlyPrintJsDocStyle`): +//! node-level leading/trailing comment scans keep `/**`-style (and `/*!`) +//! comments only, while the intervening scan inside a list keeps every +//! comment — which is also why a comment can print twice, once per enclosing +//! list's intervening scan. Which trivia a scan sees comes from +//! `iterateCommentRanges`: a trailing scan collects comments on `pos`'s +//! line, a leading one everything after the first `\r`/`\n` — except that +//! U+2028/U+2029 are whitespace for the scan while the line map counts them +//! as line breaks. +//! +//! ngtsc's `markForEmitAsSingleLine` marks the whole translated type +//! subtree. This printer always uses the single-line formats — but ngtsc's +//! `ts.transform` calls `disposeEmitNodes`, which wipes the marks on nodes +//! visited before the last `emitType` run, so in real ngtsc output a field +//! whose type is a bare tuple, type literal or mapped type can print +//! multi-line (while `i0.Foo<…>` references stay single-line via the +//! checker's reuse path). That divergence predates the comment support and +//! needs the field-order mechanism to fix; the single-line path is what the +//! fixtures exercise. Either way the comment paths need the writer's line +//! tracking: a `//` comment or a `/**` with `hasTrailingNewLine` ends the +//! line, and the next write indents it. use oxc_ast::ast::{ - BigIntLiteral, BindingPattern, Expression, FormalParameters, PropertyKey, StringLiteral, - TSLiteral, TSMappedTypeModifierOperator, TSMethodSignatureKind, TSSignature, TSThisParameter, - TSTupleElement, TSType, TSTypeAnnotation, TSTypeName, TSTypeOperatorOperator, TSTypeParameter, + BigIntLiteral, BindingPattern, BindingProperty, BindingRestElement, Comment as OxcComment, + CommentKind, Expression, FormalParameter, FormalParameterRest, FormalParameters, PropertyKey, + StringLiteral, TSIndexSignatureName, TSLiteral, TSMappedTypeModifierOperator, + TSMethodSignatureKind, TSNamedTupleMember, TSSignature, TSThisParameter, TSTupleElement, + TSType, TSTypeAnnotation, TSTypeName, TSTypeOperatorOperator, TSTypeParameter, TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypePredicateName, TSTypeQueryExprName, UnaryOperator, }; -use oxc_span::GetSpan; +use oxc_span::{GetSpan, Span}; use super::evaluator::{AliasTarget, FileScope}; use crate::output::emitter::format_number_like_js; use crate::r3::CORE; +/// TypeScript's `ListFormat` bits (`emitter.ts`) for the lists this printer +/// emits. ngtsc marks the type `EmitFlags.SingleLine`, so only the +/// single-line formats are reachable: `MultiLine`/`Indented` are defined for +/// completeness but never engage — which is also why `increaseIndent` never +/// runs and the writer indent stays at one level (four spaces). +mod fmt { + pub const BAR: u32 = 4; + pub const AMPERSAND: u32 = 8; + pub const COMMA: u32 = 16; + pub const ALLOW_TRAILING_COMMA: u32 = 64; + pub const INDENTED: u32 = 128; + pub const SPACE_BETWEEN_BRACES: u32 = 256; + pub const SPACE_BETWEEN_SIBLINGS: u32 = 512; + pub const PARENTHESIS: u32 = 1024; + pub const ANGLE: u32 = 2048; + pub const SQUARE: u32 = 8192; + pub const OPTIONAL_UNDEFINED: u32 = 16384; + pub const OPTIONAL_EMPTY: u32 = 32768; + pub const NO_INTERVENING: u32 = 262144; + pub const NO_SPACE_IF_EMPTY: u32 = 524288; + pub const SPACE_AFTER_LIST: u32 = 2097152; + + pub const DELIMITERS: u32 = BAR | AMPERSAND | COMMA | 32; // DelimitersMask + pub const BRACKETS: u32 = PARENTHESIS | ANGLE | 4096 | SQUARE; // BracketsMask + + /// `UnionTypeConstituents` / `IntersectionTypeConstituents`. + pub const UNION: u32 = BAR | SPACE_BETWEEN_SIBLINGS; + pub const INTERSECTION: u32 = AMPERSAND | SPACE_BETWEEN_SIBLINGS; + /// `TypeParameters` / `TypeArguments`. + pub const TYPE_ARGS: u32 = + OPTIONAL_UNDEFINED | OPTIONAL_EMPTY | ANGLE | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `Parameters`. + pub const PARAMETERS: u32 = PARENTHESIS | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `IndexSignatureParameters`. + pub const INDEX_PARAMETERS: u32 = SQUARE | INDENTED | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `SingleLineTypeLiteralMembers` (`NoSpaceIfEmpty` is added at the site). + pub const TYPE_LITERAL_MEMBERS: u32 = SPACE_BETWEEN_BRACES | SPACE_BETWEEN_SIBLINGS; + /// `SingleLineTupleTypeElements` (`NoSpaceIfEmpty` is added at the site). + pub const TUPLE_ELEMENTS: u32 = COMMA | SPACE_BETWEEN_SIBLINGS; + /// `ObjectBindingPatternElements`. + pub const OBJECT_BINDING: u32 = SPACE_BETWEEN_BRACES + | COMMA + | ALLOW_TRAILING_COMMA + | SPACE_BETWEEN_SIBLINGS + | NO_SPACE_IF_EMPTY; + /// `ArrayBindingPatternElements`. + pub const ARRAY_BINDING: u32 = + COMMA | ALLOW_TRAILING_COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; + /// `Modifiers`: a space-separated, comment-free list. + pub const MODIFIERS: u32 = SPACE_BETWEEN_SIBLINGS | NO_INTERVENING | SPACE_AFTER_LIST; +} + +/// The writer indent (`getIndentString`): `static` members sit at one level. +const INDENT: &str = " "; + pub(crate) struct TypePrinter<'s, 'a> { pub scope: &'s FileScope<'a>, pub source: &'a str, @@ -35,44 +114,389 @@ pub(crate) struct TypePrinter<'s, 'a> { /// since aliases numbered per source file can't be merged into bundled /// declaration files safely. pub other_module: bool, + /// TypeScript's printer dedupes comments through `containerPos` / + /// `containerEnd`: a node's leading comments are skipped when its `pos` + /// (trivia start) is the enclosing emitted node's, and its trailing + /// comments when its `end` is the enclosing node's. These hold the + /// enclosing node's range while [`Self::emit_node`] prints one. + pub container_pos: u32, + pub container_end: u32, + /// The writer's indent level (`createTextWriter`'s `indent`): `static` + /// members sit at one level, and a `ListFormat.Indented` list adds one + /// more while its items print. + pub indent: u32, + /// The source's comment table, shared across the printers of one + /// class: the backward trivia scan queries it instead of reparsing the + /// whole prefix per node. + pub(crate) lexed: std::rc::Rc, +} + +/// A list element for [`TypePrinter::emit_list`]: a node with a span plus the +/// body that prints it, like `emitListItem` calling `emit` on a `TSSignature`, +/// `Parameter`, `TypeNode`, … +enum El<'n, 'a> { + Ty(&'n TSType<'a>), + /// A `TSTupleElement` (`TSOptionalType`/`TSRestType`/`TSType`). + TupleEl(&'n TSTupleElement<'a>), + /// A `TSSignature` member of a type literal. + Member(&'n TSSignature<'a>), + /// A `TSTypeParameter`. + TypeParam(&'n TSTypeParameter<'a>), + /// A `FormalParameter`. + Param(&'n FormalParameter<'a>), + /// The `this` parameter. + This(&'n TSThisParameter<'a>), + /// A `FormalParameterRest`. + Rest(&'n FormalParameterRest<'a>), + /// An index signature's `k: T` (`TSIndexSignatureName`, a `Parameter`). + IndexParam(&'n TSIndexSignatureName<'a>), + /// A `BindingPattern` (a `BindingElement`'s name). + Binding(&'n BindingPattern<'a>), + /// A `BindingProperty` (object-pattern `key: name` or shorthand `name`). + BindingProp(&'n BindingProperty<'a>), + /// A `BindingRestElement` (`...arg`). + BindingRest(&'n BindingRestElement<'a>), + /// A span-printed token: a modifier keyword, a `?`, or an omitted array + /// element (zero length at the comma). + Token { + span: Span, + text: &'static str, + }, +} + +impl El<'_, '_> { + fn span(&self) -> Span { + match self { + El::Ty(t) => t.span(), + El::TupleEl(e) => e.span(), + El::Member(m) => m.span(), + El::TypeParam(t) => t.span, + El::Param(p) => p.span, + El::This(t) => t.span, + El::Rest(r) => r.span, + El::IndexParam(p) => p.span, + El::Binding(b) => b.span(), + El::BindingProp(p) => p.span, + El::BindingRest(r) => r.span, + El::Token { span, .. } => *span, + } + } } impl<'a> TypePrinter<'_, 'a> { /// `unknown` when the type has a form ngtsc can't emit either (an /// `import('...')` type) or that isn't valid in a type annotation. - pub(crate) fn print(&mut self, ty: &TSType<'_>) -> String { - self.ty(ty).unwrap_or_else(|| "unknown".to_string()) - } - - fn ty(&mut self, ty: &TSType<'_>) -> Option { - Some(match ty { - TSType::TSAnyKeyword(_) => "any".into(), - TSType::TSBigIntKeyword(_) => "bigint".into(), - TSType::TSBooleanKeyword(_) => "boolean".into(), - TSType::TSIntrinsicKeyword(_) => "intrinsic".into(), - TSType::TSNeverKeyword(_) => "never".into(), - TSType::TSNullKeyword(_) => "null".into(), - TSType::TSNumberKeyword(_) => "number".into(), - TSType::TSObjectKeyword(_) => "object".into(), - TSType::TSStringKeyword(_) => "string".into(), - TSType::TSSymbolKeyword(_) => "symbol".into(), - TSType::TSUndefinedKeyword(_) => "undefined".into(), - TSType::TSUnknownKeyword(_) => "unknown".into(), - TSType::TSVoidKeyword(_) => "void".into(), - TSType::TSThisType(_) => "this".into(), + pub(crate) fn print(&mut self, ty: &TSType<'a>) -> String { + let mut out = String::new(); + match self.ty_node(&mut out, ty) { + Some(()) => out, + None => "unknown".to_string(), + } + } + + /// A node's `pos` (start of its leading trivia): the end of the previous + /// token. + fn pos_of(&self, start: u32) -> u32 { + token_end_before(&self.lexed, self.source, start as usize).unwrap_or(start as usize) as u32 + } + + /// Emit a node like TypeScript's `emit(node)`: its leading comments, the + /// node body, then its trailing comments — `emitCommentsBeforeNode` / + /// `emitCommentsAfterNode` around every emitted node. `pos` is the trivia + /// start and `end` the token end; the scans dedupe against the enclosing + /// node through `container_pos` / `container_end`. With `pos == end` no + /// comments run (`emitComments*` skip that range) and nothing wraps + /// `body`. + fn emit_node( + &mut self, + out: &mut String, + pos: u32, + end: u32, + body: impl FnOnce(&mut Self, &mut String) -> Option<()>, + ) -> Option<()> { + if pos == end { + return body(self, out); + } + self.emit_leading_of_pos(out, pos as usize); + let (saved_pos, saved_end) = (self.container_pos, self.container_end); + self.container_pos = pos; + self.container_end = end; + let ok = body(self, out); + // Trailing comments compare against the *enclosing* node's range — + // the saved values are restored first, like `emitTrailingCommentsOfNode`. + self.container_pos = saved_pos; + self.container_end = saved_end; + self.emit_trailing(out, end as usize); + ok + } + + /// [`Self::emit_node`] for a `TSType`. + fn ty_node(&mut self, out: &mut String, ty: &TSType<'a>) -> Option<()> { + let pos = self.pos_of(ty.span().start); + self.emit_node(out, pos, ty.span().end, |s, o| s.ty_body(o, ty)) + } + + /// `write`/`writeComment`/`writeSpace`/`writePunctuation`: the text, with + /// the writer indent prepended at a line start (after a `writeLine`, + /// which comments emit). + fn write(&self, out: &mut String, s: &str) { + if out.ends_with('\n') { + for _ in 0..self.indent { + out.push_str(INDENT); + } + } + out.push_str(s); + } + + /// `emitNodeList` + `emitNodeListItems`. + /// + /// `items` are the list's children; `list_pos`/`list_end` are the + /// `NodeArray`'s trivia range (inside the brackets — used only for the + /// empty-list comment scans, and `list_end` again for the closing scan + /// after a trailing comma); `parent_end` is the enclosing node's `end` + /// — the gate that keeps the comments after the last element out when the + /// parent ends there too. `trailing_comma` is the `NodeArray`'s + /// `hasTrailingComma` — written only when the format allows it. + /// `open`/`close` are the bracket texts for `BracketsMask` formats. + fn emit_list( + &mut self, + out: &mut String, + items: &[El<'_, 'a>], + format: u32, + parent_end: u32, + list_pos: usize, + list_end: usize, + trailing_comma: bool, + open: Option<&'static str>, + close: Option<&'static str>, + ) -> Option<()> { + if items.is_empty() && format & fmt::OPTIONAL_EMPTY != 0 { + return Some(()); + } + if format & fmt::BRACKETS != 0 { + self.write(out, open.expect("bracketed format")); + if items.is_empty() { + // `emitTrailingCommentsOfPosition(children.pos, prefixSpace)`: + // JSDoc comments inside the empty brackets. + self.emit_trailing_filtered(out, list_pos); + } + } + if items.is_empty() { + // The MultiLine branch never runs (single-line formats only). + if format & fmt::SPACE_BETWEEN_BRACES != 0 && format & fmt::NO_SPACE_IF_EMPTY == 0 { + self.write(out, " "); + } + } else { + self.emit_list_items(out, items, format, parent_end, list_end, trailing_comma)?; + } + if format & fmt::BRACKETS != 0 { + if items.is_empty() { + // `emitLeadingCommentsOfPosition(children.end)`. + self.emit_leading_of_pos(out, list_end); + } + self.write(out, close.expect("bracketed format")); + } + Some(()) + } + + /// `emitNodeListItems`, always on the single-line path: every list this + /// printer uses is `SingleLine`-flagged, so the leading, separating and + /// closing line-terminator counts are all `0`. `Indented` still applies — + /// it's unconditional in TypeScript (index-signature parameters carry it). + fn emit_list_items( + &mut self, + out: &mut String, + items: &[El<'_, 'a>], + format: u32, + parent_end: u32, + list_end: usize, + trailing_comma: bool, + ) -> Option<()> { + let may_intervene = format & fmt::NO_INTERVENING == 0; + let mut should_intervene = may_intervene; + if format & fmt::SPACE_BETWEEN_BRACES != 0 { + self.write(out, " "); + } + // `Indented` applies unconditionally (index-signature parameters + // carry it), unlike the line-terminator counts that only exist for + // multi-line formats. + if format & fmt::INDENTED != 0 { + self.indent += 1; + } + let mut prev_end: Option = None; + for item in items { + let span = item.span(); + let pos = self.pos_of(span.start); + if prev_end.is_some() { + // The JSDoc comments between the previous element and the + // delimiter, then the delimiter, then the separator space + // (the separating-line-terminator branch never fires). + if format & fmt::DELIMITERS != 0 && prev_end != Some(parent_end) { + self.emit_leading_of_pos(out, prev_end.unwrap() as usize); + } + self.write_delimiter(out, format); + if format & fmt::SPACE_BETWEEN_SIBLINGS != 0 { + self.write(out, " "); + } + } + if should_intervene { + // `emitTrailingCommentsOfPosition(child.pos)`: the + // unfiltered same-line scan — this is what keeps `/* */` + // comments the node-level paths drop. + self.emit_intervening(out, pos as usize); + } else { + should_intervene = may_intervene; + } + self.emit_item(out, item, pos)?; + prev_end = Some(span.end); + } + // `emitTrailingComma`: the comma itself goes through + // `emitTokenWithComment` on the last element — the comments before it + // run only when that element has a real range (a zero-width elision + // has `pos == end`, which suppresses the leading scan upstream), and + // the comments after it are the comma's same-line scan. With a + // trailing comma the closing scan runs at the list's own `end` (the + // `NodeArray` ends inside the brackets), not the last element's. + let emit_trailing_comma = + trailing_comma && format & fmt::ALLOW_TRAILING_COMMA != 0 && format & fmt::COMMA != 0; + if let Some(prev) = items.last() + && emit_trailing_comma + && let Some(prev_end) = prev_end + { + let comma = skip_trivia(self.source, prev_end as usize) + 1; + if prev.span().start != prev.span().end && prev_end != parent_end { + self.emit_leading_of_pos(out, prev_end as usize); + } + self.write(out, ","); + if prev_end != parent_end { + self.emit_trailing_filtered(out, comma); + } + } + if let Some(prev_end) = prev_end + && prev_end != parent_end + && format & fmt::DELIMITERS != 0 + { + // The JSDoc comments after the last element, before the closing + // token. + self.emit_leading_of_pos( + out, + if emit_trailing_comma { list_end } else { prev_end as usize }, + ); + } + if format & fmt::INDENTED != 0 { + self.indent -= 1; + } + if format & (fmt::SPACE_AFTER_LIST | fmt::SPACE_BETWEEN_BRACES) != 0 { + self.write(out, " "); + } + Some(()) + } + + /// `writeDelimiter`: the separator token and its leading space. + fn write_delimiter(&self, out: &mut String, format: u32) { + match format & fmt::DELIMITERS { + d if d == fmt::COMMA => self.write(out, ","), + d if d == fmt::BAR => { + self.write(out, " "); + self.write(out, "|"); + } + d if d == fmt::AMPERSAND => { + self.write(out, " "); + self.write(out, "&"); + } + _ => {} + } + } + + /// `emitListItem` for one [`El`]: `emit(node)`, with the comment range of + /// the node itself. + fn emit_item(&mut self, out: &mut String, item: &El<'_, 'a>, pos: u32) -> Option<()> { + let end = item.span().end; + match item { + El::Ty(ty) => self.ty_node(out, ty), + El::TupleEl(e) => self.emit_node(out, pos, end, |s, o| s.tuple_element_body(o, e)), + El::Member(m) => self.emit_node(out, pos, end, |s, o| s.member_body(o, m)), + El::TypeParam(tp) => self.emit_node(out, pos, end, |s, o| s.type_param_body(o, tp)), + El::Param(p) => self.emit_node(out, pos, end, |s, o| s.param_body(o, p)), + El::This(t) => self.emit_node(out, pos, end, |s, o| s.this_body(o, t)), + El::Rest(r) => self.emit_node(out, pos, end, |s, o| s.rest_body(o, r)), + El::IndexParam(p) => self.emit_node(out, pos, end, |s, o| s.index_param_body(o, p)), + El::Binding(b) => self.emit_node(out, pos, end, |s, o| s.binding_body(o, b)), + El::BindingProp(p) => self.emit_node(out, pos, end, |s, o| s.binding_prop_body(o, p)), + El::BindingRest(r) => self.emit_node(out, pos, end, |s, o| s.binding_rest_body(o, r)), + El::Token { text, .. } => self.emit_node(out, pos, end, |s, o| { + s.write(o, text); + Some(()) + }), + } + } + + /// A type's body (`emit` minus the comment pipeline, which + /// [`Self::emit_node`] supplies). + fn ty_body(&mut self, out: &mut String, ty: &TSType<'a>) -> Option<()> { + match ty { + TSType::TSAnyKeyword(_) => self.write(out, "any"), + TSType::TSBigIntKeyword(_) => self.write(out, "bigint"), + TSType::TSBooleanKeyword(_) => self.write(out, "boolean"), + TSType::TSIntrinsicKeyword(_) => self.write(out, "intrinsic"), + TSType::TSNeverKeyword(_) => self.write(out, "never"), + TSType::TSNullKeyword(_) => self.write(out, "null"), + TSType::TSNumberKeyword(_) => self.write(out, "number"), + TSType::TSObjectKeyword(_) => self.write(out, "object"), + TSType::TSStringKeyword(_) => self.write(out, "string"), + TSType::TSSymbolKeyword(_) => self.write(out, "symbol"), + TSType::TSUndefinedKeyword(_) => self.write(out, "undefined"), + TSType::TSUnknownKeyword(_) => self.write(out, "unknown"), + TSType::TSVoidKeyword(_) => self.write(out, "void"), + TSType::TSThisType(_) => self.write(out, "this"), TSType::TSTypeReference(r) => { - let mut out = self.type_name(&r.type_name)?; + // ngtsc synthesizes the name (`createTypeReferenceNode`): + // a synthesized node's `pos`/`end` are -1, so it keeps no + // comments of its own — `Signal /** j */ ` prints + // `i0.Signal`. (Upstream may reuse a declaration's + // identifier and leak *its* trivia; a reference only knows + // the use site, so comments it would carry are dropped.) + let text = self.type_name(&r.type_name)?; + self.emit_node(out, 0, 0, |s, o| { + s.write(o, &text); + Some(()) + })?; if let Some(args) = &r.type_arguments { - out.push_str(&self.type_args(args)?); + self.type_args(out, args)?; } - out } - TSType::TSUnionType(u) => self.constituents(&u.types, u.span.start, "|")?, - TSType::TSIntersectionType(i) => self.constituents(&i.types, i.span.start, "&")?, - TSType::TSParenthesizedType(p) => format!("({})", self.ty(&p.type_annotation)?), - TSType::TSArrayType(a) => format!("{}[]", self.ty(&a.element_type)?), + TSType::TSUnionType(u) => { + let items: Vec = u.types.iter().map(El::Ty).collect(); + self.emit_list(out, &items, fmt::UNION, u.span.end, 0, 0, false, None, None)?; + } + TSType::TSIntersectionType(i) => { + let items: Vec = i.types.iter().map(El::Ty).collect(); + self.emit_list( + out, + &items, + fmt::INTERSECTION, + i.span.end, + 0, + 0, + false, + None, + None, + )?; + } + TSType::TSParenthesizedType(p) => { + self.write(out, "("); + self.ty_node(out, &p.type_annotation)?; + self.write(out, ")"); + } + TSType::TSArrayType(a) => { + self.ty_node(out, &a.element_type)?; + self.write(out, "[]"); + } TSType::TSIndexedAccessType(i) => { - format!("{}[{}]", self.ty(&i.object_type)?, self.ty(&i.index_type)?) + self.ty_node(out, &i.object_type)?; + self.write(out, "["); + self.ty_node(out, &i.index_type)?; + self.write(out, "]"); } TSType::TSTypeOperatorType(o) => { let op = match o.operator { @@ -80,123 +504,980 @@ impl<'a> TypePrinter<'_, 'a> { TSTypeOperatorOperator::Unique => "unique", TSTypeOperatorOperator::Readonly => "readonly", }; - format!("{op} {}", self.ty(&o.type_annotation)?) + self.write(out, op); + self.write(out, " "); + self.ty_node(out, &o.type_annotation)?; } TSType::TSTupleType(t) => { - let items = - self.elements(Some(t.span.start), &t.element_types, Self::tuple_element)?; - format!("[{}]", items.join(", ")) - } - TSType::TSNamedTupleMember(m) => format!( - "{}{}: {}", - m.label.name, - if m.optional { "?" } else { "" }, - self.tuple_element(&m.element_type)? - ), - TSType::TSLiteralType(l) => self.literal(&l.literal)?, + // `emitTokenWithComment` for both brackets: `[` carries its + // trailing same-line comments, `]` the JSDoc ones before it. + self.token_with_comment( + out, + "[", + self.pos_of(t.span.start), + t.span.start + 1, + t.span.end, + )?; + let items: Vec = t.element_types.iter().map(El::TupleEl).collect(); + let list_pos = t.span.start as usize + 1; + // A `NodeArray`'s `end` is past a trailing comma. + let list_end = t + .element_types + .last() + .map(|e| { + let after = skip_trivia(self.source, e.span().end as usize); + if self.source[after..].starts_with(',') { after + 1 } else { after } + }) + .unwrap_or(list_pos); + self.emit_list( + out, + &items, + fmt::TUPLE_ELEMENTS | fmt::NO_SPACE_IF_EMPTY, + t.span.end, + list_pos, + list_end, + false, + None, + None, + )?; + // `emitTokenWithComment(CloseBracket, elements.end, node)`: + // the leading scan runs at the element list's end — past a + // trailing comma, so `[a: string,\n /** j */]` keeps the + // comment; `]`'s own end is the tuple's, so no trailing scan. + self.token_with_comment(out, "]", list_end as u32, t.span.end - 1, t.span.end)?; + } + TSType::TSNamedTupleMember(m) => self.named_tuple_member_body(out, m, None)?, + TSType::TSLiteralType(l) => self.literal_body(out, &l.literal)?, TSType::TSTemplateLiteralType(t) => { - let mut out = String::from("`"); + // `emitTemplateType` + `emitLiteral`: each quasi goes out as + // ONE `writeStringLiteral` — `` `text${` ``, `}text${`, + // `}text`` `` — so a line break inside the text never starts + // a write (no indent): `` `a\n${string}` `` prints `${` at + // column 0. `quasi.span` covers only the cooked text, so a + // chunk is the source slice from the `` ` ``/`}` before it + // to past the `${`/`` ` `` after it. Emitting each chunk as + // a node keeps the comment scans — the `/** j */` in + // `` `${ /** j */ string}` `` is the head's trailing + // comment. for (i, quasi) in t.quasis.iter().enumerate() { - out.push_str(quasi.value.raw.as_str()); + let start = quasi.span.start as usize - 1; + let end = quasi.span.end as usize + if i + 1 == t.quasis.len() { 1 } else { 2 }; + let chunk = self.source[start..end].to_string(); + self.emit_node(out, self.pos_of(start as u32), end as u32, |s, o| { + s.write(o, &chunk); + Some(()) + })?; if let Some(ty) = t.types.get(i) { - out.push_str("${"); - out.push_str(&self.ty(ty)?); - out.push('}'); + self.ty_node(out, ty)?; } } - out.push('`'); - out } TSType::TSTypeQuery(q) => { - // `typeof x` names a value; ngtsc leaves it as written. - let mut out = match &q.expr_name { - TSTypeQueryExprName::IdentifierReference(id) => format!("typeof {}", id.name), - TSTypeQueryExprName::QualifiedName(name) => { - format!("typeof {}.{}", entity_name(&name.left)?, name.right.name) - } - TSTypeQueryExprName::ThisExpression(_) => "typeof this".into(), - TSTypeQueryExprName::TSImportType(_) => return None, - }; + // `typeof x` names a value; ngtsc emits the entity name part + // by part (`emitEntityName`), so comments inside the name are + // kept (`typeof val.a.\n/** j */\nb`). + self.write(out, "typeof "); + self.emit_query_name(out, &q.expr_name)?; if let Some(args) = &q.type_arguments { - out.push_str(&self.type_args(args)?); + self.type_args(out, args)?; } - out } - TSType::TSTypeLiteral(l) if l.members.is_empty() => "{}".into(), TSType::TSTypeLiteral(l) => { - let members = self.elements(Some(l.span.start), &l.members, Self::member)?; - format!("{{ {} }}", members.join(" ")) + self.write(out, "{"); + let items: Vec = l.members.iter().map(El::Member).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_LITERAL_MEMBERS | fmt::NO_SPACE_IF_EMPTY, + l.span.end, + l.span.start as usize + 1, + l.span.end as usize - 1, + false, + None, + None, + )?; + self.write(out, "}"); } TSType::TSMappedType(m) => { - let readonly = match m.readonly { - None => "", - Some(TSMappedTypeModifierOperator::True) => "readonly ", - Some(TSMappedTypeModifierOperator::Plus) => "+readonly ", - Some(TSMappedTypeModifierOperator::Minus) => "-readonly ", - }; - let optional = match m.optional { - None => "", - Some(TSMappedTypeModifierOperator::True) => "?", - Some(TSMappedTypeModifierOperator::Plus) => "+?", - Some(TSMappedTypeModifierOperator::Minus) => "-?", - }; - let name_type = match &m.name_type { - Some(t) => format!(" as {}", self.ty(t)?), - None => String::new(), - }; - let value = match &m.type_annotation { - Some(t) => self.ty(t)?, - None => String::new(), - }; - format!( - "{{ {readonly}[{} in {}{name_type}]{optional}: {value}; }}", - m.key.name, - self.ty(&m.constraint)? - ) - } - TSType::TSFunctionType(f) => format!( - "{}{} => {}", - self.type_params(f.type_parameters.as_deref())?, - self.params(f.this_param.as_deref(), &f.params)?, - self.ty(&f.return_type.type_annotation)? - ), - TSType::TSConstructorType(c) => format!( - "{}new {}{} => {}", - if c.r#abstract { "abstract " } else { "" }, - self.type_params(c.type_parameters.as_deref())?, - self.params(None, &c.params)?, - self.ty(&c.return_type.type_annotation)? - ), - TSType::TSConditionalType(c) => format!( - "{} extends {} ? {} : {}", - self.ty(&c.check_type)?, - self.ty(&c.extends_type)?, - self.ty(&c.true_type)?, - self.ty(&c.false_type)? - ), - TSType::TSInferType(i) => format!("infer {}", self.type_param(&i.type_parameter)?), + // `SingleLine` is always set: `{ ...; }` on one line. + self.write(out, "{ "); + if let Some(readonly) = &m.readonly { + // `readonlyToken` is `ReadonlyKeyword | PlusToken + // ReadonlyKeyword | MinusToken ReadonlyKeyword` — the sign + // and `readonly` are separate tokens (`{ - /** j */ + // readonly … }` prints `- /** j */readonly`). + let mut start = skip_trivia(self.source, m.span.start as usize + 1); + if readonly != &TSMappedTypeModifierOperator::True { + let sign = + if readonly == &TSMappedTypeModifierOperator::Plus { "+" } else { "-" }; + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, sign); + Some(()) + }, + )?; + // `writeKeyword("readonly")`: a plain write — the + // comments between sign and keyword are never + // scanned (same-line ones are already the sign's + // trailing comments). + self.write(out, "readonly"); + } else { + // `emit(node.readonlyToken)` + `writeSpace`. + self.emit_node( + out, + self.pos_of(start as u32), + (start + "readonly".len()) as u32, + |s, o| { + s.write(o, "readonly"); + Some(()) + }, + )?; + } + self.write(out, " "); + } + self.write(out, "["); + // `emitMappedTypeParameter`: `K in T` (the mapped modifiers + // live outside this node in TypeScript's AST too). + self.emit_node( + out, + self.pos_of(m.key.span.start), + m.constraint.span().end, + |s, o| { + s.emit_node(o, s.pos_of(m.key.span.start), m.key.span.end, |s2, o2| { + s2.write(o2, &m.key.name); + Some(()) + })?; + s.write(o, " in "); + s.ty_node(o, &m.constraint) + }, + )?; + let mut after = m.constraint.span().end; + if let Some(name_type) = &m.name_type { + self.write(out, " as "); + self.ty_node(out, name_type)?; + after = name_type.span().end; + } + self.write(out, "]"); + if let Some(optional) = &m.optional { + // `questionToken` is `QuestionToken | PlusToken + // QuestionToken | MinusToken QuestionToken` — separate + // tokens, like `readonlyToken` above. + let mut start = + skip_trivia(self.source, skip_trivia(self.source, after as usize) + 1); + if optional != &TSMappedTypeModifierOperator::True { + let sign = + if optional == &TSMappedTypeModifierOperator::Plus { "+" } else { "-" }; + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, sign); + Some(()) + }, + )?; + // `writePunctuation("?")`: a plain write — same + // reason as `readonly` above. + self.write(out, "?"); + } else { + // `emit(node.questionToken)`: the `?` after `]`. + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, "?"); + Some(()) + }, + )?; + } + } + self.write(out, ": "); + if let Some(value) = &m.type_annotation { + self.ty_node(out, value)?; + } + self.write(out, "; }"); + } + TSType::TSFunctionType(f) => { + // `emitFunctionTypeHead` + `emitFunctionTypeBody`. + self.type_params(out, f.type_parameters.as_deref())?; + self.params(out, f.this_param.as_deref(), &f.params, f.span.end)?; + self.write(out, " => "); + self.ty_node(out, &f.return_type.type_annotation)?; + } + TSType::TSConstructorType(c) => { + // `emitModifierList` (`abstract`), then `new `. + if c.r#abstract { + let start = skip_trivia(self.source, c.span.start as usize); + let mods = [El::Token { + span: Span::new(start as u32, start as u32 + 8), + text: "abstract", + }]; + self.emit_list( + out, + &mods, + fmt::MODIFIERS, + c.span.end, + start + 8, + start + 8, + false, + None, + None, + )?; + } + self.write(out, "new "); + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, None, &c.params, c.span.end)?; + self.write(out, " => "); + self.ty_node(out, &c.return_type.type_annotation)?; + } + TSType::TSConditionalType(c) => { + self.ty_node(out, &c.check_type)?; + self.write(out, " extends "); + self.ty_node(out, &c.extends_type)?; + self.write(out, " ? "); + self.ty_node(out, &c.true_type)?; + self.write(out, " : "); + self.ty_node(out, &c.false_type)?; + } + TSType::TSInferType(i) => { + self.write(out, "infer "); + self.type_param_body(out, &i.type_parameter)?; + } TSType::TSTypePredicate(p) => { - let name = match &p.parameter_name { - TSTypePredicateName::Identifier(id) => id.name.as_str(), - TSTypePredicateName::This(_) => "this", + if p.asserts { + // `emit(node.assertsModifier)` + `writeSpace`. + let start = skip_trivia(self.source, p.span.start as usize); + self.emit_node(out, self.pos_of(start as u32), (start + 7) as u32, |s, o| { + s.write(o, "asserts"); + Some(()) + })?; + self.write(out, " "); + } + let (span, text) = match &p.parameter_name { + TSTypePredicateName::Identifier(id) => (id.span, id.name.to_string()), + TSTypePredicateName::This(t) => (t.span, "this".to_string()), }; - let mut out = if p.asserts { format!("asserts {name}") } else { name.to_string() }; + self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { + s.write(o, &text); + Some(()) + })?; if let Some(t) = &p.type_annotation { - out.push_str(" is "); - out.push_str(&self.ty(&t.type_annotation)?); + self.write(out, " is "); + self.ty_node(out, &t.type_annotation)?; } - out } // TypeScript reports these as errors but prints them, with the // `?`/`!` in front even when it was written after the type. - TSType::JSDocNullableType(t) => format!("?{}", self.ty(&t.type_annotation)?), - TSType::JSDocNonNullableType(t) => format!("!{}", self.ty(&t.type_annotation)?), - TSType::JSDocUnknownType(_) => "?".into(), + TSType::JSDocNullableType(t) => { + self.write(out, "?"); + self.ty_node(out, &t.type_annotation)?; + } + TSType::JSDocNonNullableType(t) => { + self.write(out, "!"); + self.ty_node(out, &t.type_annotation)?; + } + TSType::JSDocUnknownType(_) => self.write(out, "?"), // ngtsc throws "Unable to emit import type" on `import('...')`. TSType::TSImportType(_) => return None, + } + Some(()) + } + + /// `emitTokenWithComment`: the JSDoc leading comments at `pos` (skipped + /// when the enclosing node starts there), the token, then its same-line + /// JSDoc comments (skipped when the enclosing node ends at `context_end` + /// — TypeScript compares against `contextNode.end`). + fn token_with_comment( + &mut self, + out: &mut String, + token: &'static str, + pos: u32, + end: u32, + context_end: u32, + ) -> Option<()> { + self.emit_leading_of_pos(out, pos as usize); + self.write(out, token); + if end != context_end { + self.emit_trailing_filtered(out, end as usize); + } + Some(()) + } + + /// `emitNamedTupleMember`: `[...]name[?]: type`, where the colon goes + /// through `emitTokenWithComment` — which is how `lbl: /** c */ T` keeps + /// its comment. `rest` is the `TSRestType` span when the member is + /// `...name: type` (its `...` is `emit`ted, not punctuation). + fn named_tuple_member_body( + &mut self, + out: &mut String, + m: &TSNamedTupleMember<'a>, + rest: Option, + ) -> Option<()> { + if let Some(rest) = rest { + self.emit_node(out, self.pos_of(rest.start), rest.start + 3, |s, o| { + s.write(o, "..."); + Some(()) + })?; + } + // `emit(node.name)`. + self.emit_node(out, self.pos_of(m.label.span.start), m.label.span.end, |s, o| { + s.write(o, &m.label.name); + Some(()) + })?; + if m.optional { + // `emit(node.questionToken)`. + let q = skip_trivia(self.source, m.label.span.end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + // `emitTokenWithComment(ColonToken, node.name.end, ...)`: the `:` is + // written where trivia after the name lands — on the `?` when + // optional — and its trailing scan runs right after that position. + let colon_end = skip_trivia(self.source, m.label.span.end as usize) + 1; + self.token_with_comment(out, ":", m.label.span.end, colon_end as u32, m.span.end)?; + self.write(out, " "); + self.tuple_element_body(out, &m.element_type) + } + + /// A tuple element (`emit` on `TSOptionalType`/`TSRestType`/`TSType`; a + /// named member is a `TSType` variant). + fn tuple_element_body(&mut self, out: &mut String, element: &TSTupleElement<'a>) -> Option<()> { + match element { + TSTupleElement::TSOptionalType(o) => { + self.ty_node(out, &o.type_annotation)?; + self.write(out, "?"); + Some(()) + } + TSTupleElement::TSRestType(r) => { + // A labeled rest (`...c: T[]`) is a named tuple member whose + // `...` is an emitted token; an unlabeled one prints `...` + // as punctuation (`emitRestOrJSDocVariadicType`). + if let TSType::TSNamedTupleMember(m) = &r.type_annotation { + self.named_tuple_member_body(out, m, Some(r.span)) + } else { + self.write(out, "..."); + self.ty_node(out, &r.type_annotation) + } + } + other => self.ty_node(out, other.to_ts_type()), + } + } + + /// `emitLiteralType` → `emitExpression`: the literal as one node, so a + /// `/** */` on its trailing edge prints. + fn literal_body(&mut self, out: &mut String, literal: &TSLiteral<'a>) -> Option<()> { + let (span, text) = match literal { + TSLiteral::BooleanLiteral(b) => (b.span, b.value.to_string()), + TSLiteral::NumericLiteral(n) => (n.span, format_number_like_js(n.value)), + TSLiteral::BigIntLiteral(b) => (b.span, self.bigint(b)), + TSLiteral::StringLiteral(s) => (s.span, quote_literal(s)), + TSLiteral::TemplateLiteral(t) if t.expressions.is_empty() => { + (t.span, self.slice(t.span)) + } + TSLiteral::UnaryExpression(u) if u.operator == UnaryOperator::UnaryNegation => ( + u.span, + match &u.argument { + Expression::NumericLiteral(n) => format!("-{}", format_number_like_js(n.value)), + Expression::BigIntLiteral(b) => format!("-{}", self.bigint(b)), + _ => return None, + }, + ), + _ => return None, + }; + self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { + s.write(o, &text); + Some(()) + }) + } + + /// TypeScript's scanner keeps hexadecimal bigints in hex (lowercased) and + /// turns the other bases into decimal, without separators. + fn bigint(&self, literal: &BigIntLiteral<'_>) -> String { + let raw = self.slice(literal.span); + match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + Some(hex) => format!("0x{}", hex.replace('_', "").to_ascii_lowercase()), + None => format!("{}n", literal.value), + } + } + + /// `emitTypeArguments` / `emitTypeParameters`: a `<…>` list. + fn type_args( + &mut self, + out: &mut String, + args: &TSTypeParameterInstantiation<'a>, + ) -> Option<()> { + let items: Vec = args.params.iter().map(El::Ty).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_ARGS, + args.span.end, + args.span.start as usize + 1, + args.span.end as usize - 1, + false, + Some("<"), + Some(">"), + ) + } + + fn type_params( + &mut self, + out: &mut String, + params: Option<&TSTypeParameterDeclaration<'a>>, + ) -> Option<()> { + let Some(params) = params else { return Some(()) }; + let items: Vec = params.params.iter().map(El::TypeParam).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_ARGS, + params.span.end, + params.span.start as usize + 1, + params.span.end as usize - 1, + false, + Some("<"), + Some(">"), + ) + } + + /// `emitTypeParameter`: modifiers, the name, `extends`, `=`. + fn type_param_body(&mut self, out: &mut String, param: &TSTypeParameter<'a>) -> Option<()> { + // The modifiers (`const`/`in`/`out`) are a `Modifiers` list in + // TypeScript's AST; oxc gives flags, so the spans come from the text + // in order. + let mut mods: Vec = Vec::new(); + let mut at = param.span.start as usize; + for (set, word) in [(param.r#const, "const"), (param.r#in, "in"), (param.out, "out")] { + if set { + let start = skip_trivia(self.source, at); + mods.push(El::Token { + span: Span::new(start as u32, start as u32 + word.len() as u32), + text: word, + }); + at = start + word.len(); + } + } + if !mods.is_empty() { + self.emit_list(out, &mods, fmt::MODIFIERS, param.span.end, at, at, false, None, None)?; + } + self.emit_node(out, self.pos_of(param.name.span.start), param.name.span.end, |s, o| { + s.write(o, ¶m.name.name); + Some(()) + })?; + if let Some(constraint) = ¶m.constraint { + self.write(out, " extends "); + self.ty_node(out, constraint)?; + } + if let Some(default) = ¶m.default { + self.write(out, " = "); + self.ty_node(out, default)?; + } + Some(()) + } + + /// `emitParameters`: a `(…)` list of parameters. + fn params( + &mut self, + out: &mut String, + this: Option<&TSThisParameter<'a>>, + params: &FormalParameters<'a>, + parent_end: u32, + ) -> Option<()> { + let mut items: Vec = Vec::new(); + if let Some(this) = this { + items.push(El::This(this)); + } + for param in ¶ms.items { + if param.initializer.is_some() { + return None; + } + items.push(El::Param(param)); + } + if let Some(rest) = ¶ms.rest { + items.push(El::Rest(rest)); + } + self.emit_list( + out, + &items, + fmt::PARAMETERS, + parent_end, + params.span.start as usize + 1, + params.span.end as usize - 1, + false, + Some("("), + Some(")"), + ) + } + + /// `emitParameter`: modifiers, `...`, the name, `?`, `: type`. + fn param_body(&mut self, out: &mut String, param: &FormalParameter<'a>) -> Option<()> { + if param.readonly { + let start = skip_trivia(self.source, param.span.start as usize); + let mods = + [El::Token { span: Span::new(start as u32, start as u32 + 8), text: "readonly" }]; + self.emit_list( + out, + &mods, + fmt::MODIFIERS, + param.span.end, + start + 8, + start + 8, + false, + None, + None, + )?; + } + self.emit_node( + out, + self.pos_of(param.pattern.span().start), + param.pattern.span().end, + |s, o| s.binding_body(o, ¶m.pattern), + )?; + if param.optional { + let q = skip_trivia(self.source, param.pattern.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + self.annotation(out, param.type_annotation.as_deref()) + } + + /// The `this` parameter: `this` (`emitNodeWithWriter`) then `: type`. + fn this_body(&mut self, out: &mut String, this: &TSThisParameter<'a>) -> Option<()> { + self.emit_node(out, self.pos_of(this.this_span.start), this.this_span.end, |s, o| { + s.write(o, "this"); + Some(()) + })?; + self.annotation(out, this.type_annotation.as_deref()) + } + + /// A `FormalParameterRest` (`emitParameter` with a `...` token). + fn rest_body(&mut self, out: &mut String, rest: &FormalParameterRest<'a>) -> Option<()> { + // `emit(node.dotDotDotToken)`. + self.emit_node( + out, + self.pos_of(rest.rest.span.start), + rest.rest.span.start + 3, + |s, o| { + s.write(o, "..."); + Some(()) + }, + )?; + self.emit_node( + out, + self.pos_of(rest.rest.argument.span().start), + rest.rest.argument.span().end, + |s, o| s.binding_body(o, &rest.rest.argument), + )?; + self.annotation(out, rest.type_annotation.as_deref()) + } + + /// An index signature's parameter (`emitParameter`): `name: type`. The + /// name ends where `:` starts, so comments between them are trivia of + /// the annotation — same as TypeScript's `Parameter`. + fn index_param_body( + &mut self, + out: &mut String, + param: &TSIndexSignatureName<'a>, + ) -> Option<()> { + let name_start = skip_trivia(self.source, param.span.start as usize); + let name_end = + token_end_before(&self.lexed, self.source, param.type_annotation.span.start as usize) + .unwrap_or(param.type_annotation.span.start as usize); + self.emit_node(out, self.pos_of(name_start as u32), name_end as u32, |s, o| { + s.write(o, param.name.as_str()); + Some(()) + })?; + self.annotation(out, Some(¶m.type_annotation)) + } + + /// `emitParametersForIndexSignature`: a `[…]` list. + fn index_params( + &mut self, + out: &mut String, + param: &TSIndexSignatureName<'a>, + parent_end: u32, + ) -> Option<()> { + let items = [El::IndexParam(param)]; + let list_pos = self.pos_of(param.span.start) as usize; + self.emit_list( + out, + &items, + fmt::INDEX_PARAMETERS, + parent_end, + list_pos, + param.span.end as usize, + false, + Some("["), + Some("]"), + ) + } + + /// `: T`, or nothing — `emitTypeAnnotation`. The `:` is plain + /// punctuation; the type keeps its own comments (a same-line one after + /// `:` is trivia of the type and drops, like ngtsc). + fn annotation( + &mut self, + out: &mut String, + annotation: Option<&TSTypeAnnotation<'a>>, + ) -> Option<()> { + if let Some(a) = annotation { + self.write(out, ": "); + self.ty_node(out, &a.type_annotation)?; + } + Some(()) + } + + /// A member of a type literal (`emitSignature`), ending in `;`. + fn member_body(&mut self, out: &mut String, member: &TSSignature<'a>) -> Option<()> { + match member { + TSSignature::TSPropertySignature(p) => { + if p.readonly { + self.modifiers( + out, + p.span.start, + p.span.end, + &[(p.span.start, 8, "readonly")], + )?; + } + self.key_node(out, &p.key, p.computed)?; + if p.optional { + let q = skip_trivia(self.source, p.key.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + self.annotation(out, p.type_annotation.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSIndexSignature(s) => { + if s.readonly { + self.modifiers( + out, + s.span.start, + s.span.end, + &[(s.span.start, 8, "readonly")], + )?; + } + self.index_params(out, &s.parameter, s.span.end)?; + self.annotation(out, Some(&s.type_annotation))?; + self.write(out, ";"); + } + TSSignature::TSCallSignatureDeclaration(c) => { + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, c.this_param.as_deref(), &c.params, c.span.end)?; + self.annotation(out, c.return_type.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSConstructSignatureDeclaration(c) => { + self.write(out, "new "); + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, None, &c.params, c.span.end)?; + self.annotation(out, c.return_type.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSMethodSignature(m) => { + // `get`/`set` are modifiers in TypeScript's AST. + if m.kind != TSMethodSignatureKind::Method { + let (word, len) = match m.kind { + TSMethodSignatureKind::Get => ("get", 3), + TSMethodSignatureKind::Set => ("set", 3), + TSMethodSignatureKind::Method => unreachable!(), + }; + self.modifiers(out, m.span.start, m.span.end, &[(m.span.start, len, word)])?; + } + self.key_node(out, &m.key, m.computed)?; + if m.optional { + let q = skip_trivia(self.source, m.key.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + // `emitSignatureHead` + `emitEmptyFunctionBody`. + self.type_params(out, m.type_parameters.as_deref())?; + self.params(out, m.this_param.as_deref(), &m.params, m.span.end)?; + self.annotation(out, m.return_type.as_deref())?; + self.write(out, ";"); + } + } + Some(()) + } + + /// `emitModifierList` for keywords oxc exposes as flags. `from` is where + /// the modifier text is searched for (the node's trivia end), `end` the + /// owning node's `end`, and `mods` `(search-from, byte-len, text)` in + /// order. + fn modifiers( + &mut self, + out: &mut String, + from: u32, + end: u32, + mods: &[(u32, u32, &'static str)], + ) -> Option<()> { + let items: Vec = mods + .iter() + .map(|&(_, len, text)| { + let start = skip_trivia(self.source, from as usize); + El::Token { span: Span::new(start as u32, start as u32 + len), text } + }) + .collect(); + self.emit_list(out, &items, fmt::MODIFIERS, end, 0, 0, false, None, None) + } + + /// A property name as one node — `emitNodeWithWriter(node.name)` — or a + /// computed one (`emitComputedPropertyName`): `[`, the expression, `]`. + fn key_node(&mut self, out: &mut String, key: &PropertyKey<'a>, computed: bool) -> Option<()> { + if computed { + // `ComputedPropertyName`'s range: from the trivia before `[` to + // `]`'s end. oxc's key span covers the expression only. + let bracket_end = token_end_before(&self.lexed, self.source, key.span().start as usize) + .unwrap_or(key.span().start as usize); + let pos = token_end_before(&self.lexed, self.source, bracket_end - 1) + .unwrap_or(bracket_end - 1); + let end = skip_trivia(self.source, key.span().end as usize) + 1; + let text = self.key_text(key)?; + self.emit_node(out, pos as u32, end as u32, |s, o| { + s.write(o, "["); + s.emit_node(o, bracket_end as u32, key.span().end, |s2, o2| { + s2.write(o2, &text); + Some(()) + })?; + s.write(o, "]"); + Some(()) + }) + } else { + let pos = self.pos_of(key.span().start); + let end = key.span().end; + let text = self.key_text(key)?; + self.emit_node(out, pos, end, |s, o| { + s.write(o, &text); + Some(()) + }) + } + } + + /// A property name's text, re-quoted like any other string literal. A + /// computed name (`[token]`, `[ns.token]`) follows the rules of a type + /// name: an `@angular/core` value becomes `i0.token`, another module's + /// makes the type `unknown`, and a local or global one stays as written + /// (ngtsc copies the expression, which doesn't resolve for an import). + /// + /// Through an import-equals alias too, although ngtsc copies those as + /// written: an alias of `@angular/core` gives `i0.token` and one of + /// another module `unknown`, where ngtsc's name doesn't resolve in the + /// `.d.ts`. An alias of a global is written as its target + /// (`[Symbol.iterator]` for `import S = Symbol`), and one of a name the + /// file declares stays as written, like ngtsc. + fn key_text(&mut self, key: &PropertyKey<'a>) -> Option { + Some(match key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => quote_literal(s), + PropertyKey::NumericLiteral(n) => format_number_like_js(n.value), + PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), + PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?, + PropertyKey::StaticMemberExpression(m) => { + let mut parts = std::vec::Vec::new(); + member_parts(&m.object, &mut parts)?; + parts.push(m.property.name.as_str()); + self.computed_name(parts)? + } + _ => return None, + }) + } + + /// A computed property name `a.b.c` (`parts`, head first), as + /// [`Self::key_text`] writes it. + fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option { + let written = parts.join("."); + Some(match self.resolve_aliases(parts)? { + Resolved::Core(rest) => format!("{}{rest}", self.core_ns), + Resolved::OtherModule(_) => { + self.other_module = true; + written + } + Resolved::Name(parts, aliased) => { + let (head, members) = (parts[0], &parts[1..]); + if aliased && self.scope.import(head).is_none() && self.scope.declares(head) { + written + } else { + let rest: String = members.iter().map(|m| format!(".{m}")).collect(); + self.value_name(head, &rest) + } + } }) } + /// A `BindingPattern` node (`emit(name)` for a parameter's name). + fn binding_body(&mut self, out: &mut String, pattern: &BindingPattern<'a>) -> Option<()> { + match pattern { + BindingPattern::BindingIdentifier(id) => self.write(out, &id.name), + BindingPattern::ObjectPattern(o) => { + self.write(out, "{"); + let mut items: Vec = o.properties.iter().map(El::BindingProp).collect(); + if let Some(rest) = &o.rest { + items.push(El::BindingRest(rest)); + } + // `ObjectBindingPatternElements` allows a trailing comma: + // `{a,}` prints `{ a, }`. + let trailing = items + .last() + .is_some_and(|el| self.trailing_comma_after(el.span().end as usize)); + self.emit_list( + out, + &items, + fmt::OBJECT_BINDING, + o.span.end, + o.span.start as usize + 1, + o.span.end as usize - 1, + trailing, + None, + None, + )?; + self.write(out, "}"); + } + BindingPattern::ArrayPattern(a) => { + self.write(out, "["); + let mut items: Vec = Vec::new(); + // Each element ends at a comma (or `]`). An elision is an + // `OmittedExpression`: zero width right after the comma that + // precedes it — trivia before that comma belongs to the + // previous element, trivia after it is the elision's own. + let mut cursor = a.span.start as usize + 1; + let mut trailing = false; + for element in &a.elements { + match element { + Some(element) => { + items.push(El::Binding(element)); + cursor = element.span().end as usize; + } + None => { + items.push(El::Token { + span: Span::new(cursor as u32, cursor as u32), + text: "", + }); + } + } + let after = skip_trivia(self.source, cursor); + trailing = self.source[after..].starts_with(','); + cursor = after + 1; + } + if let Some(rest) = &a.rest { + items.push(El::BindingRest(rest)); + trailing = self.trailing_comma_after(rest.span.end as usize); + } + self.emit_list( + out, + &items, + fmt::ARRAY_BINDING, + a.span.end, + a.span.start as usize + 1, + a.span.end as usize - 1, + trailing, + None, + None, + )?; + self.write(out, "]"); + } + // Default values aren't allowed in a type. + BindingPattern::AssignmentPattern(_) => return None, + } + Some(()) + } + + /// A `BindingElement`: `propertyName: name`, or just `name` for + /// shorthand (`emitBindingElement`; initializers can't appear in a type). + fn binding_prop_body( + &mut self, + out: &mut String, + property: &BindingProperty<'a>, + ) -> Option<()> { + if !property.shorthand { + self.key_node(out, &property.key, property.computed)?; + self.write(out, ": "); + } + self.emit_node( + out, + self.pos_of(property.value.span().start), + property.value.span().end, + |s, o| s.binding_body(o, &property.value), + ) + } + + /// A `RestElement`: `...` then its argument (`emitRestElement` emits the + /// `dotDotDotToken` with its comments). + fn binding_rest_body(&mut self, out: &mut String, rest: &BindingRestElement<'a>) -> Option<()> { + self.token_with_comment(out, "...", rest.span.start, rest.span.start + 3, rest.span.end)?; + self.emit_node( + out, + self.pos_of(rest.argument.span().start), + rest.argument.span().end, + |s, o| s.binding_body(o, &rest.argument), + ) + } + + /// `emitEntityName` for a `typeof` operand: each part is `emit`ted (so + /// comments between parts survive), `.` between them. + fn emit_query_name(&mut self, out: &mut String, name: &TSTypeQueryExprName<'a>) -> Option<()> { + match name { + TSTypeQueryExprName::IdentifierReference(id) => { + self.emit_node(out, self.pos_of(id.span.start), id.span.end, |s, o| { + s.write(o, id.name.as_str()); + Some(()) + }) + } + TSTypeQueryExprName::QualifiedName(q) => { + self.emit_name_left(out, &q.left)?; + self.write(out, "."); + self.emit_node(out, self.pos_of(q.right.span.start), q.right.span.end, |s, o| { + s.write(o, q.right.name.as_str()); + Some(()) + }) + } + TSTypeQueryExprName::ThisExpression(e) => { + self.emit_node(out, self.pos_of(e.span.start), e.span.end, |s, o| { + s.write(o, "this"); + Some(()) + }) + } + TSTypeQueryExprName::TSImportType(_) => None, + } + } + + /// `emitEntityName`'s `left` recursion. + fn emit_name_left(&mut self, out: &mut String, name: &TSTypeName<'a>) -> Option<()> { + match name { + TSTypeName::IdentifierReference(id) => { + self.emit_node(out, self.pos_of(id.span.start), id.span.end, |s, o| { + s.write(o, id.name.as_str()); + Some(()) + }) + } + TSTypeName::QualifiedName(q) => { + self.emit_name_left(out, &q.left)?; + self.write(out, "."); + self.emit_node(out, self.pos_of(q.right.span.start), q.right.span.end, |s, o| { + s.write(o, q.right.name.as_str()); + Some(()) + }) + } + TSTypeName::ThisExpression(e) => { + self.emit_node(out, self.pos_of(e.span.start), e.span.end, |s, o| { + s.write(o, "this"); + Some(()) + }) + } + } + } + /// `@angular/core` names become `i0.Name`; local and global names stay as /// written; names from other modules set `other_module`. /// @@ -299,344 +1580,187 @@ impl<'a> TypePrinter<'_, 'a> { } } - /// A union's or intersection's constituents, joined by ` | ` / ` & `. - fn constituents(&mut self, types: &[TSType<'_>], start: u32, op: &str) -> Option { - // After a leading `|`/`&`, the first constituent follows that token; - // otherwise it shares the start of the union (see `leading_comments`). - let open = self.source[start as usize..].starts_with(op).then_some(start); - Some(self.elements(open, types, Self::ty)?.join(&format!(" {op} "))) - } - - fn type_args(&mut self, args: &TSTypeParameterInstantiation<'_>) -> Option { - Some(format!( - "<{}>", - self.elements(Some(args.span.start), &args.params, Self::ty)?.join(", ") - )) - } - - fn type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) -> Option { - let Some(params) = params else { return Some(String::new()) }; - let items = self.elements(Some(params.span.start), ¶ms.params, Self::type_param)?; - Some(format!("<{}>", items.join(", "))) - } + // ---- Comment emission (TypeScript's `onlyPrintJsDocStyle` paths) ---- - fn type_param(&mut self, param: &TSTypeParameter<'_>) -> Option { - let mut out = String::new(); - if param.r#const { - out.push_str("const "); - } - if param.r#in { - out.push_str("in "); - } - if param.out { - out.push_str("out "); - } - out.push_str(¶m.name.name); - if let Some(constraint) = ¶m.constraint { - out.push_str(" extends "); - out.push_str(&self.ty(constraint)?); - } - if let Some(default) = ¶m.default { - out.push_str(" = "); - out.push_str(&self.ty(default)?); + /// `emitLeadingCommentsOfPosition(pos)` — the JSDoc-only leading scan, + /// gated on `containerPos` (`forEachLeadingCommentToEmit`). + fn emit_leading_of_pos(&self, out: &mut String, pos: usize) { + if pos as u32 == self.container_pos { + return; } - Some(out) + self.emit_leading(out, pos); } - fn tuple_element(&mut self, element: &TSTupleElement<'_>) -> Option { - match element { - TSTupleElement::TSOptionalType(o) => Some(format!("{}?", self.ty(&o.type_annotation)?)), - TSTupleElement::TSRestType(r) => Some(format!("...{}", self.ty(&r.type_annotation)?)), - other => self.ty(other.to_ts_type()), - } - } - - fn literal(&self, literal: &TSLiteral<'_>) -> Option { - Some(match literal { - TSLiteral::BooleanLiteral(b) => b.value.to_string(), - TSLiteral::NumericLiteral(n) => format_number_like_js(n.value), - TSLiteral::BigIntLiteral(b) => self.bigint(b), - TSLiteral::StringLiteral(s) => quote_literal(s), - TSLiteral::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), - TSLiteral::UnaryExpression(u) if u.operator == UnaryOperator::UnaryNegation => { - match &u.argument { - Expression::NumericLiteral(n) => format!("-{}", format_number_like_js(n.value)), - Expression::BigIntLiteral(b) => format!("-{}", self.bigint(b)), - _ => return None, + /// `emitLeadingComments(pos)` — comments after the first line break in + /// the trivia at `pos`, JSDoc-style only. The first one gets a preceding + /// newline when it starts on a different line than `pos` + /// (`emitNewLineBeforeLeadingCommentOfPosition`), and each is followed by + /// a newline when a line break follows it (`hasTrailingNewLine`), else a + /// space for `/* */`. + fn emit_leading(&self, out: &mut String, pos: usize) { + let mut first = true; + for c in comment_ranges(self.source, pos, false) { + if !self.is_jsdoc_comment(c.pos) { + continue; + } + if first && line_of(self.source, c.pos) != line_of(self.source, pos) { + if !out.ends_with('\n') { + out.push('\n'); } } - _ => return None, - }) - } - - /// TypeScript's scanner keeps hexadecimal bigints in hex (lowercased) and - /// turns the other bases into decimal, without separators. - fn bigint(&self, literal: &BigIntLiteral<'_>) -> String { - let raw = self.slice(literal.span); - match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { - Some(hex) => format!("0x{}", hex.replace('_', "").to_ascii_lowercase()), - None => format!("{}n", literal.value), + first = false; + self.emit_comment_text(out, &c); + if c.trailing_newline { + if !out.ends_with('\n') { + out.push('\n'); + } + } else if !c.line { + out.push(' '); + } } } - fn member(&mut self, member: &TSSignature<'_>) -> Option { - Some(match member { - TSSignature::TSPropertySignature(p) => format!( - "{}{}{}{};", - if p.readonly { "readonly " } else { "" }, - self.key(&p.key, p.computed)?, - if p.optional { "?" } else { "" }, - self.annotation(p.type_annotation.as_deref())? - ), - TSSignature::TSIndexSignature(s) => { - let param = &s.parameter; - format!( - "{}[{}{}: {}]: {};", - if s.readonly { "readonly " } else { "" }, - self.leading_comments(None, param.span.start), - param.name, - self.ty(¶m.type_annotation.type_annotation)?, - self.ty(&s.type_annotation.type_annotation)? - ) - } - TSSignature::TSCallSignatureDeclaration(c) => format!( - "{}{}{};", - self.type_params(c.type_parameters.as_deref())?, - self.params(c.this_param.as_deref(), &c.params)?, - self.annotation(c.return_type.as_deref())? - ), - TSSignature::TSConstructSignatureDeclaration(c) => format!( - "new {}{}{};", - self.type_params(c.type_parameters.as_deref())?, - self.params(None, &c.params)?, - self.annotation(c.return_type.as_deref())? - ), - TSSignature::TSMethodSignature(m) => format!( - "{}{}{}{}{}{};", - match m.kind { - TSMethodSignatureKind::Method => "", - TSMethodSignatureKind::Get => "get ", - TSMethodSignatureKind::Set => "set ", - }, - self.key(&m.key, m.computed)?, - if m.optional { "?" } else { "" }, - self.type_params(m.type_parameters.as_deref())?, - self.params(m.this_param.as_deref(), &m.params)?, - self.annotation(m.return_type.as_deref())? - ), - }) - } - - /// `: T`, or nothing when there's no annotation. - fn annotation(&mut self, annotation: Option<&TSTypeAnnotation<'_>>) -> Option { - match annotation { - Some(t) => Some(format!(": {}", self.ty(&t.type_annotation)?)), - None => Some(String::new()), + /// `emitTrailingComments(end)` — comments on `end`'s line, JSDoc-style + /// only, each preceded by a space unless at a line start; gated on + /// `containerEnd` like `forEachTrailingCommentToEmit`. + fn emit_trailing(&self, out: &mut String, end: usize) { + if end as u32 == self.container_end { + return; } - } - - /// A property name, re-quoted like any other string literal. A computed - /// name (`[token]`, `[ns.token]`) follows the rules of a type name: an - /// `@angular/core` value becomes `i0.token`, another module's makes the - /// type `unknown`, and a local or global one stays as written (ngtsc copies - /// the expression, which doesn't resolve for an import). - /// - /// Through an import-equals alias too, although ngtsc copies those as - /// written: an alias of `@angular/core` gives `i0.token` and one of another - /// module `unknown`, where ngtsc's name doesn't resolve in the `.d.ts`. An - /// alias of a global is written as its target (`[Symbol.iterator]` for - /// `import S = Symbol`), and one of a name the file declares stays as - /// written, like ngtsc. - fn key(&mut self, key: &PropertyKey<'_>, computed: bool) -> Option { - let text = match key { - PropertyKey::StaticIdentifier(id) => id.name.to_string(), - PropertyKey::StringLiteral(s) => quote_literal(s), - PropertyKey::NumericLiteral(n) => format_number_like_js(n.value), - PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), - PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?, - PropertyKey::StaticMemberExpression(m) => { - let mut parts = std::vec::Vec::new(); - member_parts(&m.object, &mut parts)?; - parts.push(m.property.name.as_str()); - self.computed_name(parts)? + for c in comment_ranges(self.source, end, true) { + if !self.is_jsdoc_comment(c.pos) { + continue; } - _ => return None, - }; - Some(if computed { format!("[{text}]") } else { text }) + if !out.is_empty() && !out.ends_with('\n') { + out.push(' '); + } + self.emit_comment_text(out, &c); + if c.trailing_newline && !out.ends_with('\n') { + out.push('\n'); + } + } } - /// A computed property name `a.b.c` (`parts`, head first), as [`Self::key`] - /// writes it. - fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option { - let written = parts.join("."); - Some(match self.resolve_aliases(parts)? { - Resolved::Core(rest) => format!("{}{rest}", self.core_ns), - Resolved::OtherModule(_) => { - self.other_module = true; - written + /// `emitTrailingCommentsOfPosition(pos, prefixSpace: true)` — the + /// JSDoc-filtered same-line scan `emitTokenWithComment` and the + /// empty-brackets path use. Ungated (the position paths don't check + /// `containerEnd`). + fn emit_trailing_filtered(&self, out: &mut String, pos: usize) { + for c in comment_ranges(self.source, pos, true) { + if !self.is_jsdoc_comment(c.pos) { + continue; } - Resolved::Name(parts, aliased) => { - let (head, members) = (parts[0], &parts[1..]); - if aliased && self.scope.import(head).is_none() && self.scope.declares(head) { - written - } else { - let rest: String = members.iter().map(|m| format!(".{m}")).collect(); - self.value_name(head, &rest) - } + if !out.is_empty() && !out.ends_with('\n') { + out.push(' '); } - }) + self.emit_comment_text(out, &c); + if c.trailing_newline && !out.ends_with('\n') { + out.push('\n'); + } + } } - fn params( - &mut self, - this: Option<&TSThisParameter<'_>>, - params: &FormalParameters<'_>, - ) -> Option { - let mut out = Vec::new(); - let mut prev = params.span.start; - if let Some(this) = this { - let comments = self.leading_comments(Some(prev), this.span.start); - out.push(format!( - "{comments}this{}", - self.annotation(this.type_annotation.as_deref())? - )); - prev = this.span.end; - } - for param in ¶ms.items { - if param.initializer.is_some() { - return None; + /// `emitTrailingCommentsOfPosition(pos)` with the default callback — + /// comments on `pos`'s line, unfiltered, each followed by a space (or a + /// newline for `hasTrailingNewLine`, which `//` and U+2028-following + /// `/* */` have). This is what prints `/* */` comments the JSDoc-filtered + /// paths drop — and prints them again for each enclosing list. + fn emit_intervening(&self, out: &mut String, pos: usize) { + for c in comment_ranges(self.source, pos, true) { + self.emit_comment_text(out, &c); + if c.trailing_newline { + if !out.ends_with('\n') { + out.push('\n'); + } + } else { + out.push(' '); } - let comments = self.leading_comments(Some(prev), param.span.start); - out.push(format!( - "{comments}{}{}{}", - self.binding(¶m.pattern)?, - if param.optional { "?" } else { "" }, - self.annotation(param.type_annotation.as_deref())? - )); - prev = param.span.end; } - if let Some(rest) = ¶ms.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - out.push(format!( - "{comments}...{}{}", - self.binding(&rest.rest.argument)?, - self.annotation(rest.type_annotation.as_deref())? - )); - } - Some(format!("({})", out.join(", "))) } - /// A parameter name or destructuring pattern. - fn binding(&mut self, pattern: &BindingPattern<'_>) -> Option { - match pattern { - BindingPattern::BindingIdentifier(id) => Some(id.name.to_string()), - BindingPattern::ObjectPattern(o) if o.properties.is_empty() && o.rest.is_none() => { - Some("{}".into()) + /// The declaration printer's `shouldWriteComment`: `/**`-style or `/*!` + /// (pinned) comments only — `/*` and `//` are dropped on node-level + /// leading/trailing paths. + fn is_jsdoc_comment(&self, pos: usize) -> bool { + let b = self.source.as_bytes(); + b.get(pos + 1) == Some(&b'*') + && (b.get(pos + 2) == Some(&b'!') + || (b.get(pos + 2) == Some(&b'*') && b.get(pos + 3) != Some(&b'/'))) + } + + /// `writeCommentRange`: a `//` comment verbatim; a `/* */` with each line + /// trimmed, continuation lines re-indented relative to the line the + /// comment starts on, rebased to the writer's indent. + fn emit_comment_text(&self, out: &mut String, comment: &Comment) { + // `writeComment` goes through `writeText`, which indents a line + // started by a `writeLine`. + if out.ends_with('\n') { + for _ in 0..self.indent { + out.push_str(INDENT); } - BindingPattern::ObjectPattern(o) => { - let mut items = Vec::new(); - let mut prev = o.span.start; - for property in &o.properties { - let value = self.binding(&property.value)?; - let item = if property.shorthand { - value - } else { - format!("{}: {value}", self.key(&property.key, property.computed)?) - }; - items.push(self.leading_comments(Some(prev), property.span.start) + &item); - prev = property.span.end; + } + let text = &self.source[comment.pos..comment.end]; + if comment.line { + out.push_str(text); + return; + } + let Some(first_break) = text.find(is_line_break) else { + out.push_str(text); + return; + }; + // The first line, trimmed; `writeTrimmedCurrentLine` writes a + // `writeLine` after it when the comment continues. + out.push_str(text[..first_break].trim()); + out.push('\n'); + // `calculateIndent` of the line the comment starts on (the whitespace + // before `/*`), then each continuation line's indent minus that, + // rebased to the writer's indent. + let first_indent = + indent_of(&self.source[line_start_of(self.source, comment.pos)..comment.pos]); + let mut rest = &text[first_break..]; + loop { + rest = &rest[line_break_width(rest)..]; + let (line, next) = match rest.find(is_line_break) { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + // `writeTrimmedCurrentLine` writes a bare newline for an + // all-whitespace continuation. + out.push('\n'); + } else { + let spaces = + 4 * self.indent as isize - first_indent as isize + indent_of(line) as isize; + for _ in 0..spaces.max(0) { + out.push(' '); } - if let Some(rest) = &o.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); + out.push_str(trimmed); + if !next.is_empty() { + out.push('\n'); } - Some(format!("{{ {} }}", items.join(", "))) } - BindingPattern::ArrayPattern(a) => { - let mut items = Vec::new(); - let mut prev = a.span.start; - for element in &a.elements { - let Some(element) = element else { - items.push(String::new()); - continue; - }; - let comments = self.leading_comments(Some(prev), element.span().start); - items.push(comments + &self.binding(element)?); - prev = element.span().end; - } - if let Some(rest) = &a.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); - } - Some(format!("[{}]", items.join(", "))) + if next.is_empty() { + break; } - // Default values aren't allowed in a type. - BindingPattern::AssignmentPattern(_) => None, + rest = next; } } - /// Prints each element of a list, with the comments TypeScript keeps in - /// front of it. `open` is where the list's opening token (`<`, `(`, `{`, - /// `[`, a leading `|`) starts; `None` when the list has none, so the token - /// before the first element is looked for backwards. - fn elements( - &mut self, - open: Option, - items: &[T], - mut print: impl FnMut(&mut Self, &T) -> Option, - ) -> Option> { - let mut prev = open; - items - .iter() - .map(|item| { - let span = item.span(); - let comments = self.leading_comments(prev, span.start); - prev = Some(span.end); - Some(comments + &print(self, item)?) - }) - .collect() - } - - /// The comments TypeScript's printer keeps in front of a list element: - /// those right after the token before it, on that token's line (it emits - /// them as trailing comments of that position). Other comments in the type - /// are dropped, as ngtsc does, except `/** */` ones after a type or on - /// their own line, which ngtsc keeps and oxc doesn't. - /// - /// `prev` is where to look for that token from: the end of the previous - /// element (the token is the delimiter, if any) or the opening bracket. - /// Without it the token is searched for backwards from `start`, which is - /// how a union's first constituent finds the token before the union. - fn leading_comments(&self, prev: Option, start: u32) -> String { - let start = start as usize; - let pos = match prev { - Some(prev) => after_token(self.source, prev as usize, start), - None => match token_end_before(self.source, start) { - Some(pos) => pos, - None => return String::new(), - }, - }; - same_line_comments(&self.source[pos..start]) + /// `NodeArray.hasTrailingComma`: a `,` between the last element's end + /// and the closing token. + fn trailing_comma_after(&self, end: usize) -> bool { + self.source[skip_trivia(self.source, end)..].starts_with(',') } - fn slice(&self, span: oxc_span::Span) -> String { + fn slice(&self, span: Span) -> String { span.source_text(self.source).to_string() } } -/// A `typeof` operand or computed key: `a.b.c`, as ngtsc prints it (without -/// the source's spacing). -fn entity_name(name: &TSTypeName<'_>) -> Option { - match name { - TSTypeName::IdentifierReference(id) => Some(id.name.to_string()), - TSTypeName::QualifiedName(q) => Some(format!("{}.{}", entity_name(&q.left)?, q.right.name)), - TSTypeName::ThisExpression(_) => Some("this".into()), - } -} - /// A type name's parts, head first (`["A", "B", "T"]` for `A.B.T`). `None` /// for one starting with `this`. -fn entity_parts<'n>(name: &'n TSTypeName<'_>, out: &mut Vec<&'n str>) -> Option<()> { +fn entity_parts<'a, 'n>(name: &'n TSTypeName<'a>, out: &mut Vec<&'n str>) -> Option<()> { match name { TSTypeName::IdentifierReference(id) => out.push(id.name.as_str()), TSTypeName::QualifiedName(q) => { @@ -671,96 +1795,292 @@ enum Resolved<'p> { Name(std::vec::Vec<&'p str>, bool), } +/// TypeScript's `isLineBreak`: `\r`, `\n`, U+2028, U+2029. fn is_line_break(c: char) -> bool { matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') } +/// TypeScript's `isWhiteSpaceSingleLine`: a fixed list — space, tab, VT, FF, +/// NBSP, NEL, ogham, the U+2000–U+200B range (enQuad through zeroWidthSpace), +/// narrow NBSP, mathematical space, ideographic space and the BOM. Rust's +/// `is_whitespace` differs on both ends (it lacks NEL and ZWSP and has +/// U+001C–U+001F), so the list is spelled out. fn is_space(c: char) -> bool { - c.is_whitespace() && !is_line_break(c) + matches!( + c, + ' ' | '\t' | '\u{B}' | '\u{C}' | '\u{A0}' | '\u{85}' | '\u{1680}' | '\u{2000}' + ..='\u{200B}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}' + ) } -/// The end of the first token at or after `from`, when one comes before -/// `start`; `from` itself otherwise. -fn after_token(source: &str, from: usize, start: usize) -> usize { - let text = &source[from..start]; - let mut rest = text; +/// `skipTrivia`: the position of the first token character at or after +/// `pos`, past whitespace and comments. +fn skip_trivia(source: &str, mut pos: usize) -> usize { loop { - rest = rest.trim_start(); - if let Some(body) = rest.strip_prefix("/*") { - match body.find("*/") { - Some(end) => rest = &body[end + 2..], - None => return from, + let Some(c) = char_at(source, pos) else { return pos }; + if is_whitespace_like(c) { + pos += c.len_utf8(); + } else if source[pos..].starts_with("/*") { + match source[pos + 2..].find("*/") { + Some(end) => pos += 2 + end + 2, + None => return source.len(), } - } else if rest.starts_with("//") { - match rest.find(is_line_break) { - Some(end) => rest = &rest[end..], - None => return from, + } else if source[pos..].starts_with("//") { + match source[pos..].find(is_line_break) { + Some(end) => pos += end, + None => return source.len(), } } else { - return match rest.chars().next() { - Some(c) => from + (text.len() - rest.len()) + c.len_utf8(), - None => from, - }; + return pos; } } } /// Where the token before `start` ends, skipping whitespace and comments -/// backwards. `None` when that crosses a line with `//` on it: a line comment -/// can't be told apart from code when reading backwards. -fn token_end_before(source: &str, start: usize) -> Option { +/// backwards. A `//` on the line above makes that line end at the `//` — +/// anything after it is comment — so the token ends at the `//` (or earlier, +/// on the line before it). `//` inside a string or a `/* */` doesn't count, +/// and the `/*` a trailing `*/` pairs with is the first one after the +/// previous `*/`, not the last. +fn token_end_before(lexed: &Lexed, source: &str, start: usize) -> Option { let mut end = start; loop { let before = &source[..end]; - let trimmed = before.trim_end(); - if before[trimmed.len()..].contains(is_line_break) { - // After the line break, which can be more than one byte (U+2028). - let line_start = trimmed - .char_indices() - .rfind(|&(_, c)| is_line_break(c)) - .map_or(0, |(i, c)| i + c.len_utf8()); - if trimmed[line_start..].contains("//") { - return None; + // TypeScript's trivia, not Rust's Unicode whitespace: the trim must + // take `isWhiteSpaceSingleLine` bytes like U+200B/U+FEFF and leave + // the line breaks (`\u{2028}` included — a `//` after one still + // counts for the same check above). + let trimmed_len = before.trim_end_matches(is_whitespace_like).len(); + if before[trimmed_len..].contains(is_line_break) { + if let Some(slashes) = line_comment_start(lexed, &before[..trimmed_len]) { + // The line ends at the comment; keep looking before it. + end = slashes; + continue; } } - end = trimmed.len(); + end = trimmed_len; + let trimmed = &before[..trimmed_len]; match trimmed.strip_suffix("*/") { - Some(inner) => end = inner.rfind("/*")?, + Some(inner) => end = block_comment_start(lexed, inner)?, None => return Some(end), } } } -/// The comments at the start of `text`, up to the first line break, as -/// TypeScript prints them there: `/* */` followed by a space, `//` by a new -/// line (indented like the `.d.ts` class members). Comments spanning lines, -/// which TypeScript re-indents, are dropped with the rest. -fn same_line_comments(text: &str) -> String { - let mut out = String::new(); - let mut rest = text; - loop { - rest = rest.trim_start_matches(is_space); - if let Some(body) = rest.strip_prefix("/*") { - let Some(end) = body.find("*/") else { break }; - let comment = &rest[..end + 4]; - if comment.contains(is_line_break) { - return String::new(); - } - out.push_str(comment); - out.push(' '); - rest = &rest[comment.len()..]; - } else { - if rest.starts_with("//") { - let end = rest.find(is_line_break).unwrap_or(rest.len()); - out.push_str(&rest[..end]); - out.push_str("\n "); +/// The source's comments: sorted `(start, end)` ranges of every `//` and +/// `/* */`, with delimiters — `end` of a `//` is the line break (or the +/// file's end), of `/* */` past the closer. Built from the parser's own +/// comment list, so strings, template text, and regex literals are already +/// skipped: a `/[/*]/` — after `=`, after `return`, after `)`, anywhere — +/// can't pair its `/*` with a comment's `*/` inside the type, and the +/// whole file is lexed once rather than re-scanned per `pos_of` call. +#[derive(Default)] +pub(crate) struct Lexed { + /// `//` comment ranges, sorted by start. + line: Vec<(usize, usize)>, + /// `/* */` comment ranges, sorted by start. + block: Vec<(usize, usize)>, +} + +impl Lexed { + /// Builds the table from the parser's comment list for one source file. + pub(crate) fn from_comments(comments: &[OxcComment]) -> Self { + let mut out = Self::default(); + for c in comments { + let range = (c.span.start as usize, c.span.end as usize); + match c.kind { + CommentKind::Line => out.line.push(range), + _ => out.block.push(range), } - break; } + out.line.sort_unstable(); + out.block.sort_unstable(); + out + } +} + +/// The `//` opening a line comment that runs to the end of `code` — so it +/// sits on the last line and bounds the trivia — or `None`. The ranges are +/// sorted and disjoint, so the only `//` that can cover `code.len()` is the +/// last one starting before it. +fn line_comment_start(lexed: &Lexed, code: &str) -> Option { + let i = lexed.line.partition_point(|(start, _)| *start < code.len()); + match lexed.line.get(i.wrapping_sub(1)) { + Some(&(start, end)) if i > 0 && end >= code.len() => Some(start), + _ => None, + } +} + +/// The `/*` that opens the comment closed by the `*/` just cut off `inner`: +/// the `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a +/// comment of its own — or comment text — and is skipped whole). Same +/// binary-search shape as [`line_comment_start`]. +fn block_comment_start(lexed: &Lexed, inner: &str) -> Option { + let i = lexed.block.partition_point(|(start, _)| *start < inner.len()); + match lexed.block.get(i.wrapping_sub(1)) { + Some(&(start, end)) if i > 0 && end > inner.len() => Some(start), + _ => None, + } +} +/// A comment's source range, as TypeScript's `iterateCommentRanges` reports +/// it. +struct Comment { + pos: usize, + end: usize, + /// `//`: the comment ends at a line break. + line: bool, + /// A line break follows the comment (`hasTrailingNewLine`): always true + /// for `//`, true for `/* */` when a `\r`, `\n`, U+2028 or U+2029 + /// follows while the scan collects it. + trailing_newline: bool, +} + +/// `getLeadingCommentRanges` / `getTrailingCommentRanges` (`scanner.ts` +/// `iterateCommentRanges`): the comments in the trivia starting at `pos`. +/// +/// A trailing scan collects comments up to the first `\r`/`\n` — those on +/// `pos`'s line. A leading scan skips that same-line prefix and collects +/// everything after it. The quirk ngtsc's doubled comments ride on: +/// U+2028/U+2029 are whitespace here (a trailing scan crosses them, and they +/// don't start collecting for a leading one), while the line map counts them +/// as line breaks. +fn comment_ranges(source: &str, start: usize, trailing: bool) -> Vec { + let mut out = Vec::new(); + // Pending = a comment seen while collecting; flushed when the next + // comment starts or the scan ends. + let mut pending: Option = None; + let mut collecting = trailing || start == 0; + let bytes = source.as_bytes(); + let mut pos = start; + while pos < bytes.len() { + let b = bytes[pos]; + match b { + b'\r' | b'\n' => { + if b == b'\r' && bytes.get(pos + 1) == Some(&b'\n') { + pos += 1; + } + pos += 1; + if trailing { + break; + } + collecting = true; + if let Some(c) = &mut pending { + c.trailing_newline = true; + } + } + b' ' | b'\t' | 0x0B | 0x0C => pos += 1, + b'/' => { + let next = bytes.get(pos + 1); + let is_line = next == Some(&b'/'); + if next != Some(&b'/') && next != Some(&b'*') { + break; + } + let comment_start = pos; + pos += 2; + let mut trailing_newline = false; + if is_line { + while let Some(c) = char_at(source, pos) { + if is_line_break(c) { + trailing_newline = true; + break; + } + pos += c.len_utf8(); + } + } else { + while pos < bytes.len() { + if bytes[pos] == b'*' && bytes.get(pos + 1) == Some(&b'/') { + pos += 2; + break; + } + pos += 1; + } + } + if collecting { + if let Some(c) = pending.take() { + out.push(c); + } + pending = Some(Comment { + pos: comment_start, + end: pos, + line: is_line, + trailing_newline, + }); + } + } + _ => { + // Non-ASCII whitespace — including U+2028/U+2029, which are + // whitespace here (a trailing scan crosses them; they don't + // start a leading scan collecting) while the line map still + // counts them as line breaks. + match char_at(source, pos) { + Some(c) if (c as u32) > 0x7F && is_whitespace_like(c) => { + if is_line_break(c) + && let Some(p) = &mut pending + { + p.trailing_newline = true; + } + pos += c.len_utf8(); + } + _ => break, + } + } + } + } + if let Some(c) = pending { + out.push(c); } out } +/// TypeScript's `isWhiteSpaceLike`: single-line whitespace or a line break. +fn is_whitespace_like(c: char) -> bool { + is_space(c) || is_line_break(c) +} + +/// The character at byte `pos`, or `None` mid-character/past the end. +fn char_at(source: &str, pos: usize) -> Option { + source.get(pos..)?.chars().next() +} + +/// The 1-based-agnostic line index of `pos` (number of line breaks before +/// it), where U+2028/U+2029 count — matching TypeScript's line map. +fn line_of(source: &str, pos: usize) -> usize { + source[..pos].chars().filter(|&c| is_line_break(c)).count() +} + +/// Byte index where `pos`'s line starts (just past the last line break). +fn line_start_of(source: &str, pos: usize) -> usize { + source[..pos] + .char_indices() + .rfind(|&(_, c)| is_line_break(c)) + .map_or(0, |(i, c)| i + c.len_utf8()) +} + +/// `calculateIndent`: the width of `text`'s leading whitespace, tabs rounded +/// to the next multiple of 4. +fn indent_of(text: &str) -> usize { + let mut indent = 0; + for c in text.chars() { + if c == '\t' { + indent += 4 - indent % 4; + } else if is_space(c) { + indent += 1; + } else { + break; + } + } + indent +} + +/// Width in bytes of the line break `text` starts with. +fn line_break_width(text: &str) -> usize { + match text.as_bytes().first() { + Some(b'\r') if text.as_bytes().get(1) == Some(&b'\n') => 2, + Some(b'\r') | Some(b'\n') => 1, + _ => text.chars().next().map_or(0, char::len_utf8), + } +} + /// A string literal as TypeScript prints a synthesized one: double quotes, /// escapes for control characters and everything outside ASCII (as UTF-16 /// code units). diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index e718e1b78..b4e27834a 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3504,6 +3504,7 @@ pub fn input_transform_types<'a>( class: &'a Class<'a>, consts: &super::StringConsts<'a>, source: &'a str, + comments: &'a [oxc_ast::ast::Comment], core_namespace: &'a str, ) -> HashMap { let evaluator = Evaluator::new(consts); @@ -3557,6 +3558,7 @@ pub fn input_transform_types<'a>( } } + let lexed = std::rc::Rc::new(super::dts_type::Lexed::from_comments(comments)); transforms .into_iter() .filter_map(|(name, transform)| { @@ -3571,6 +3573,10 @@ pub fn input_transform_types<'a>( source, core_ns: core_namespace, other_module: false, + container_pos: u32::MAX, + container_end: u32::MAX, + indent: 1, + lexed: lexed.clone(), }; let ty = printer.print(ty); if printer.other_module { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 0f9721561..ecc039b42 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -22,6 +22,9 @@ export interface Gen { v: T } export const val = {a: 1}; export const kc = 'kc'; export function gen(x: T) { return x; } +const re = /[/*]/; +function f() { return /[/*]/; } +const tpl = `${re}`; @Directive({selector: '[t]'}) export class T { @Input({transform: (v: %TYPE%) => 1}) t: any; @@ -71,51 +74,100 @@ fn matches_ngtsc() { check(MATCH); } -/// Comments ngtsc keeps that oxc drops: `/** */` comments after a type or -/// on their own line (TypeScript's printer keeps only those two kinds of -/// comment around nodes), and comments spanning lines. Each row is the type, -/// what ngtsc writes and what oxc writes; they differ only by the comment. -const COMMENTS_NOT_KEPT: &[(&str, &str, &str)] = &[ - ("{\n /** doc */\n a: 1\n}", "{ \n /** doc */\n a: 1; }", "{ a: 1; }"), - ("string | /* a\n b */ number", "string | /* a\n b */ number", "string | number"), - ("string /** d */ | number", "string /** d */ | number", "string | number"), - ("{ a: 1 /** y */ }", "{ a: 1; /** y */ }", "{ a: 1; }"), - ("(x: string /** y */) => void", "(x: string /** y */) => void", "(x: string) => void"), - ("Array", "Array", "Array"), - ("string |\n /** j */ number", "string | \n /** j */ number", "string | number"), +/// Comments ngtsc keeps around a type: `/** */` comments after a type or on +/// their own line (TypeScript's printer keeps only those two kinds of comment +/// around nodes), `/* */` comments a list scan picks up, and comments +/// spanning lines. Each row is the type and what ngtsc writes. +const COMMENTS_KEPT: &[(&str, &str)] = &[ + ("string /** doc */", "string /** doc */"), + ("string | number /** doc */", "string | number /** doc */"), + ("\n/** doc */\nstring", "\n /** doc */\n string"), + ("Array", "Array"), + ("{\n /** doc */\n a: 1\n}", "{ \n /** doc */\n a: 1; }"), + ("string | /* a\n b */ number", "string | /* a\n b */ number"), + ("string /** d */ | number", "string /** d */ | number"), + ("{ a: 1 /** y */ }", "{ a: 1; /** y */ }"), + ("(x: string /** y */) => void", "(x: string /** y */) => void"), + ("Array", "Array"), + ("string |\n /** j */ number", "string | \n /** j */ number"), // TypeScript reads past a U+2028 / U+2029 for the comments after `<` (it - // only stops at `\n` and `\r`), and prints this one twice. + // only stops at `\n` and `\r`), and prints this one twice: once for the + // type-arguments list, once for the union's constituents list. + ("Array<\u{2028}/* c */\nstring | number>", "Array"), + ("Array<\u{2029}/* c */\nstring | number>", "Array"), + // `//` and `/*` inside strings and comments are comment text, not trivia. + ("{ a: 'x//y'\n b: 1 }", "{ a: \"x//y\"; b: 1; }"), + ("string | /* a // b */\n number", "string | /* a // b */ number"), + ("string | /** a /** b */\nnumber", "string | /** a /** b */ number"), + // ngtsc synthesizes the type name of a reference (its `pos`/`end` are -1), + // so a comment between the name and the type arguments is dropped. + ("Signal /** j */ ", "i0.Signal"), + // `]` after a trailing comma scans from the list's end (past the comma). + ("[a: string,\n /** j */]", "[a: string\n /** j */ ]"), + // Mapped-type `±readonly` and `±?` are two tokens each. The keyword is + // a plain write after a sign — a comment after a line break is never + // scanned. + ("{ - /** j */ readonly [K in string]: 1 }", "{ - /** j */readonly [K in string]: 1; }"), + ("{ -\n/** j */\nreadonly [K in string]: 1 }", "{ -readonly [K in string]: 1; }"), + ("{ [K in string]-\n/** j */\n?: 1 }", "{ [K in string]-?: 1; }"), + ("{ [K in string] - /** j */ ?: 1 }", "{ [K in string]- /** j */?: 1; }"), + // The `...` of a binding rest and the `${` of a template literal are + // emitted tokens too. + ("({ ... /** j */ rest }: Box) => void", "({ ... /** j */rest }: Box) => void"), + ("`${ /** j */ string}`", "`${ /** j */string}`"), + // A quasi goes out as one literal write (TypeScript's `emitLiteral`), so + // line breaks inside its text never start a write: nothing is indented. + ("`a\n${string}`", "`a\n${string}`"), + ("`a\n${string}b\n${number}`", "`a\n${string}b\n${number}`"), + ("`a\n`", "`a\n`"), + ("`${string}x\n`", "`${string}x\n`"), + // A `//` on a `/*`'s continuation line is comment text. + ("string | /* a\n // b */\n number", "string | /* a\n // b */ number"), + // The `/*` inside the file's `/[/*]/` regexes (after `=` and after the + // `return` keyword) doesn't pair with this `*/`. + ("string | /* a\n b */ number", "string | /* a\n b */ number"), + // A template ending in a hole (`${re}` in the file) doesn't hide later + // comments. + ("string | /* c */ number", "string | /* c */ number"), + ("[`${string}`, /** x */ number]", "[`${string}`, /** x */ number]"), + // `isWhiteSpaceSingleLine` covers U+200B: the JSDoc stays `number`'s. + ("string | /** doc */\u{200B} number", "string | /** doc */ number"), + // An elision is zero-width right after its preceding comma: comments + // before the comma stay with the previous element, comments after it + // are the elision's intervening trivia. + ("([a /** j */, , b]: any[]) => void", "([a /** j */, , b]: any[]) => void"), + ("([a, , /* x */, , b]: any[]) => void", "([a, , /* x */ , , b]: any[]) => void"), ( - "Array<\u{2028}/* c */\nstring | number>", - "Array", - "Array", - ), - ( - "Array<\u{2029}/* c */\nstring | number>", - "Array", - "Array", + "([a /** j */, , /* x */, , b]: any[]) => void", + "([a /** j */, , /* x */ , , b]: any[]) => void", ), + ("([, , a]: any[]) => void", "([, , a]: any[]) => void"), + // `AllowTrailingComma` is on both binding formats: `[a, ,]` keeps its + // trailing comma, and the comments around it are the elision's and the + // comma's own scans. + ("([a, ,]: any[]) => void", "([a, ,]: any[]) => void"), + ("([a,]: any[]) => void", "([a,]: any[]) => void"), + ("({a,}: any) => void", "({ a, }: any) => void"), + ("([a /** j */,]: any[]) => void", "([a /** j */,]: any[]) => void"), + ("([a, /** j */,]: any[]) => void", "([a, /** j */ ,]: any[]) => void"), + ("([a, , /** j */,]: any[]) => void", "([a, , /** j */ ,]: any[]) => void"), + ("([a, , /** j */, b]: any[]) => void", "([a, , /** j */ , b]: any[]) => void"), + // Comments inside a template's `${}` holes count during the backward + // trivia scan — the hole is code; the opening backtick isn't an + // unterminated string. + ("`${ /** j */\n string}`", "`${ /** j */string}`"), + ("`${\n/** doc */\nstring}`", "`${\n /** doc */\n string}`"), + ("`x${string /** j */ }y`", "`x${string /** j */}y`"), + ("`${string}t${ /** k */ number}z`", "`${string}t${ /** k */number}z`"), + // A `typeof` name's parts are emitted separately. + ("typeof val.a.\n/** j */\nb", "typeof val.a.\n /** j */\n b"), + // NEL (U+0085) is whitespace in TypeScript's trivia scans. + ("(/* a */\u{85}/** b */ x: string) => void", "(/* a */ /** b */ x: string) => void"), ]; #[test] -fn comments_not_kept() { - // Without comments and whitespace, ngtsc's text is oxc's. - let bare = |text: &str| { - let mut out = String::new(); - let mut rest = text; - while let Some(at) = rest.find("/*") { - out.push_str(&rest[..at]); - rest = &rest[at + rest[at..].find("*/").unwrap() + 2..]; - } - out.push_str(rest); - out.split_whitespace().collect::() - }; - for (ty, ngtsc, oxc) in COMMENTS_NOT_KEPT { - assert_eq!(bare(ngtsc), bare(oxc), "{ty:?}"); - } - let cases: Vec<(&str, &str)> = - COMMENTS_NOT_KEPT.iter().map(|(ty, _, oxc)| (*ty, *oxc)).collect(); - check(&cases); +fn comments_kept() { + check(COMMENTS_KEPT); } /// ngtsc adds `import * as i1 from './other'` and writes `i1.Other`; oxc From 250510ce7f0dc9d2deb9abca5b9dcb0294aa1c00 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 9 Oct 2026 10:12:25 +0100 Subject: [PATCH 3/8] fix(compiler): resolve arrow function parameters in template expressions (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compiler): resolve arrow function parameters in template expressions An arrow function in a template that read its own parameter was compiled as though the parameter were a component property: `open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined` at runtime with no diagnostic. Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a raw AST blob that skipped name resolution, and reify then turned every implicit-receiver read in it into `ctx.`. That also meant template variables and, in embedded views, the component itself were read off the wrong `ctx` inside an arrow body. - ingest.rs: convert arrow functions to IR and port Angular's `updateParameterReferences`, which replaces reads of an arrow's parameters (and of enclosing arrows' parameters) with plain variable reads before name resolution runs. Covers templates and host bindings. - resolve_names.rs: resolve the remaining names in an arrow body against template variables and the component context. - reify/ir_expression.rs: emit the arrow in place. - var_counting.rs: an in-place arrow does not reserve a var slot. Verified against ngtsc 22.2.1. Arrows in event bindings now match it, including the four listener cases ported from Angular's `r3_view_compiler_arrow_functions` compliance suite. Outside listeners ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not implemented yet: the arrow is still emitted in place, now with the same body as ngtsc's hoisted function. * fix(compiler): close arrow-parameter resolution gaps found in review Follow-up to the listener-arrow fix, addressing cases where arrow parameters still emitted ctx.-prefixed reads: - generate_arrow_functions: only top-level arrows join view.functions (matching upstream, where unit.functions holds just the non-listener arrows created by addArrowFunctions); listener and nested arrows are emitted in place and no longer receive dead variable ops, naming, or save/restore processing. Collected arrows are marked with a var_offset sentinel so var_counting can distinguish them; collected arrows consume a slot per upstream varsUsedByIrExpression. - ir: IN_ARROW_FUNCTION_OPERATION is set only inside top-level arrow bodies, as upstream applies it; arrow ops lists are traversed. - resolve_dollar_event: LexicalRead('$event') inside IR handler expressions resolves to a plain ReadVar, matching upstream. - resolve_names: resolve_angular_expression resolves arrow parameters inside stored AST expressions (Chain, and other raw-AST contexts) with a shared grow-only parameter set, matching updateParameterReferences. - ingest: convert LiteralArray, LiteralMap, TemplateLiteral and Empty on the host path too, so host bindings no longer store them as opaque AST blobs that skip name resolution. - ir: new TaggedTemplateLiteral and SpreadElement expression kinds so arrows inside tagged templates and spreads are converted and resolved instead of stored as raw AST; threaded through emit, reify, generate_advance, next_context_merging, resolve_dollar_event, store_let_optimization, strip_nonrequired_parentheses, temporary_variables and variable_optimization. - save_restore_view: move the handler expression into ResetView instead of cloning, preserving pointer identity for collected arrows. Verified: cargo test -p oxc_angular_compiler 3090/3090, conformance 1264/1264, fixture compare vs ngtsc 22.2.1 994 fixtures 0 mismatches. --------- Co-authored-by: LongYinan --- crates/oxc_angular_compiler/src/ir/enums.rs | 4 + .../oxc_angular_compiler/src/ir/expression.rs | 124 ++++- .../src/pipeline/compilation.rs | 6 + .../oxc_angular_compiler/src/pipeline/emit.rs | 44 ++ .../src/pipeline/ingest.rs | 300 ++++++++++++- .../src/pipeline/phases/generate_advance.rs | 12 + .../phases/generate_arrow_functions.rs | 64 ++- .../pipeline/phases/next_context_merging.rs | 20 + .../pipeline/phases/reify/ir_expression.rs | 87 +++- .../pipeline/phases/resolve_dollar_event.rs | 92 +++- .../src/pipeline/phases/resolve_names.rs | 424 +++++++++++++++--- .../src/pipeline/phases/save_restore_view.rs | 7 +- .../pipeline/phases/store_let_optimization.rs | 61 +++ .../phases/strip_nonrequired_parentheses.rs | 9 + .../pipeline/phases/temporary_variables.rs | 9 + .../src/pipeline/phases/var_counting.rs | 25 +- .../pipeline/phases/variable_optimization.rs | 55 +++ .../tests/arrow_function_scope_test.rs | 255 +++++++++++ .../tests/integration_test.rs | 7 +- .../templates/arrow-functions.fixture.ts | 169 +++++++ 20 files changed, 1637 insertions(+), 137 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs create mode 100644 napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts diff --git a/crates/oxc_angular_compiler/src/ir/enums.rs b/crates/oxc_angular_compiler/src/ir/enums.rs index ca3f80db3..84aeae0fa 100644 --- a/crates/oxc_angular_compiler/src/ir/enums.rs +++ b/crates/oxc_angular_compiler/src/ir/enums.rs @@ -260,6 +260,10 @@ pub enum ExpressionKind { Void, /// Template literal with resolved expressions (used after name resolution). ResolvedTemplateLiteral, + /// Tagged template literal with resolved tag and template expressions. + TaggedTemplateLiteral, + /// Spread element (...expr) inside a call or other expression list. + SpreadElement, /// Arrow function expression. ArrowFunction, /// Parenthesized expression. diff --git a/crates/oxc_angular_compiler/src/ir/expression.rs b/crates/oxc_angular_compiler/src/ir/expression.rs index 27bcb02b4..e20b82741 100644 --- a/crates/oxc_angular_compiler/src/ir/expression.rs +++ b/crates/oxc_angular_compiler/src/ir/expression.rs @@ -191,6 +191,12 @@ pub enum IrExpression<'a> { /// Used when a template literal like `\`bwi ${menuItem.icon}\`` has expressions /// that need to be resolved to variables in scope. ResolvedTemplateLiteral(Box<'a, ResolvedTemplateLiteralExpr<'a>>), + /// Tagged template literal (tag`...`) with IR tag and template expressions. + /// Used to preserve pipes and arrow functions inside tagged templates during ingest. + TaggedTemplateLiteral(Box<'a, IrTaggedTemplateLiteralExpr<'a>>), + /// Spread element (...expr) with an IR inner expression. + /// Used to preserve pipes and arrow functions inside spread positions during ingest. + SpreadElement(Box<'a, IrSpreadElementExpr<'a>>), /// Arrow function expression. /// Created by the generateArrowFunctions phase to wrap user-defined arrow functions. ArrowFunction(Box<'a, ArrowFunctionExpr<'a>>), @@ -267,6 +273,10 @@ impl<'a> IrExpression<'a> { IrExpression::Void(_) => ExpressionKind::Void, // ResolvedTemplateLiteral is a template literal with resolved expressions IrExpression::ResolvedTemplateLiteral(_) => ExpressionKind::ResolvedTemplateLiteral, + // TaggedTemplateLiteral is a tagged template literal with IR sub-expressions + IrExpression::TaggedTemplateLiteral(_) => ExpressionKind::TaggedTemplateLiteral, + // SpreadElement is a spread element with an IR inner expression + IrExpression::SpreadElement(_) => ExpressionKind::SpreadElement, // ArrowFunction is an arrow function expression IrExpression::ArrowFunction(_) => ExpressionKind::ArrowFunction, IrExpression::Parenthesized(_) => ExpressionKind::Parenthesized, @@ -780,6 +790,35 @@ impl<'a> IrExpression<'a> { &allocator, )) } + IrExpression::TaggedTemplateLiteral(e) => { + let mut elements = Vec::with_capacity_in(e.elements.len(), &allocator); + for elem in e.elements.iter() { + elements.push(IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: elem.source_span, + }); + } + let mut expressions = Vec::with_capacity_in(e.expressions.len(), &allocator); + for expr in e.expressions.iter() { + expressions.push(expr.clone_in(allocator)); + } + IrExpression::TaggedTemplateLiteral(Box::new_in( + IrTaggedTemplateLiteralExpr { + tag: Box::new_in(e.tag.clone_in(allocator), &allocator), + elements, + expressions, + source_span: e.source_span, + }, + &allocator, + )) + } + IrExpression::SpreadElement(e) => IrExpression::SpreadElement(Box::new_in( + IrSpreadElementExpr { + expr: Box::new_in(e.expr.clone_in(allocator), &allocator), + source_span: e.source_span, + }, + &allocator, + )), IrExpression::ArrowFunction(e) => { let mut params = Vec::with_capacity_in(e.params.len(), &allocator); for param in e.params.iter() { @@ -1073,6 +1112,34 @@ pub struct IrTemplateLiteralElement<'a> { pub source_span: Option, } +/// Tagged template literal (tag`...`) with IR sub-expressions. +/// +/// Used during ingest to preserve pipes and arrow functions inside the tag and +/// the template's interpolated expressions. +#[derive(Debug)] +pub struct IrTaggedTemplateLiteralExpr<'a> { + /// The tag function expression. + pub tag: Box<'a, IrExpression<'a>>, + /// Template literal text elements (the static parts between expressions). + pub elements: Vec<'a, IrTemplateLiteralElement<'a>>, + /// The dynamic parts inside ${...}. + pub expressions: Vec<'a, IrExpression<'a>>, + /// Source span. + pub source_span: Option, +} + +/// Spread element (...expr) with an IR inner expression. +/// +/// Used during ingest to preserve pipes and arrow functions inside spread +/// positions (e.g. call arguments). +#[derive(Debug)] +pub struct IrSpreadElementExpr<'a> { + /// The expression being spread. + pub expr: Box<'a, IrExpression<'a>>, + /// Source span. + pub source_span: Option, +} + /// Derived literal array for pure function bodies. /// This is used when a literal array contains non-constant entries that need /// to be replaced with PureFunctionParameter references. @@ -1714,11 +1781,29 @@ pub fn transform_expressions_in_expression<'a, F>( transform_expressions_in_expression(expr, transform, flags); } } + IrExpression::TaggedTemplateLiteral(e) => { + transform_expressions_in_expression(&mut e.tag, transform, flags); + for expr in e.expressions.iter_mut() { + transform_expressions_in_expression(expr, transform, flags); + } + } + IrExpression::SpreadElement(e) => { + transform_expressions_in_expression(&mut e.expr, transform, flags); + } IrExpression::ArrowFunction(e) => { - // Transform body with InChildOperation and InArrowFunctionOperation flags set - let child_flags = flags - .union(VisitorContextFlag::IN_CHILD_OPERATION) - .union(VisitorContextFlag::IN_ARROW_FUNCTION_OPERATION); + // Mirror ir.ArrowFunctionExpr.transformInternalExpressions: ops are + // transformed as child operations inside an arrow function. The + // InArrowFunctionOperation flag only applies to top-level (collected) + // arrows; arrows preserved in place inside child operations (listener + // handlers, nested arrows) get only InChildOperation, matching the + // o.ArrowFunctionExpr branch of transformExpressionsInExpression. + let mut child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION); + if !flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { + child_flags = child_flags.union(VisitorContextFlag::IN_ARROW_FUNCTION_OPERATION); + } + for op in e.ops.iter_mut() { + transform_expressions_in_update_op(op, transform, child_flags); + } transform_expressions_in_expression(&mut e.body, transform, child_flags); } IrExpression::Parenthesized(e) => { @@ -1901,11 +1986,25 @@ pub fn visit_expressions_in_expression<'a, F>( visit_expressions_in_expression(expr, visitor, flags); } } + IrExpression::TaggedTemplateLiteral(e) => { + visit_expressions_in_expression(&e.tag, visitor, flags); + for expr in e.expressions.iter() { + visit_expressions_in_expression(expr, visitor, flags); + } + } + IrExpression::SpreadElement(e) => { + visit_expressions_in_expression(&e.expr, visitor, flags); + } IrExpression::ArrowFunction(e) => { - // Visit body with InChildOperation and InArrowFunctionOperation flags set - let child_flags = flags - .union(VisitorContextFlag::IN_CHILD_OPERATION) - .union(VisitorContextFlag::IN_ARROW_FUNCTION_OPERATION); + // See transform_expressions_in_expression for the flag rationale: + // InArrowFunctionOperation applies only to top-level (collected) arrows. + let mut child_flags = flags.union(VisitorContextFlag::IN_CHILD_OPERATION); + if !flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { + child_flags = child_flags.union(VisitorContextFlag::IN_ARROW_FUNCTION_OPERATION); + } + for op in e.ops.iter() { + visit_expressions_in_update_op(op, visitor, child_flags); + } visit_expressions_in_expression(&e.body, visitor, child_flags); } IrExpression::Parenthesized(e) => { @@ -3003,6 +3102,15 @@ pub fn vars_used_by_ir_expression(expr: &IrExpression<'_>) -> u32 { rtl.expressions.iter().map(vars_used_by_ir_expression).sum() } + // TaggedTemplateLiteral: vars used by tag and template expressions + IrExpression::TaggedTemplateLiteral(ttl) => { + vars_used_by_ir_expression(&ttl.tag) + + ttl.expressions.iter().map(vars_used_by_ir_expression).sum::() + } + + // SpreadElement: vars used by the inner expression + IrExpression::SpreadElement(spread) => vars_used_by_ir_expression(&spread.expr), + // All other expressions don't directly consume variable slots IrExpression::LexicalRead(_) | IrExpression::Reference(_) diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index 3717b6e14..8d5074c0b 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -806,6 +806,11 @@ pub struct HostBindingCompilationUnit<'a> { pub vars: Option, /// Generated function name. pub fn_name: Option>, + /// Arrow functions found in this unit. + /// + /// Populated by the generateArrowFunctions phase. Only non-listener, + /// top-level arrows are collected, matching upstream `unit.functions`. + pub functions: Vec<'a, *mut crate::ir::expression::ArrowFunctionExpr<'a>>, } impl<'a> HostBindingCompilationUnit<'a> { @@ -820,6 +825,7 @@ impl<'a> HostBindingCompilationUnit<'a> { attributes: None, vars: None, fn_name: None, + functions: Vec::new_in(&allocator), } } } diff --git a/crates/oxc_angular_compiler/src/pipeline/emit.rs b/crates/oxc_angular_compiler/src/pipeline/emit.rs index 2907b3438..516b28e44 100644 --- a/crates/oxc_angular_compiler/src/pipeline/emit.rs +++ b/crates/oxc_angular_compiler/src/pipeline/emit.rs @@ -1320,6 +1320,50 @@ fn convert_pure_function_body<'a>( &allocator, )) } + // TaggedTemplateLiteral: convert tag and template expressions + IrExpression::TaggedTemplateLiteral(ttl) => { + let tag = convert_pure_function_body(allocator, &ttl.tag, params); + let mut elements = OxcVec::new_in(&allocator); + let mut expressions = OxcVec::new_in(&allocator); + for elem in ttl.elements.iter() { + elements.push(crate::output::ast::TemplateLiteralElement { + text: elem.text.clone(), + raw_text: elem.text.clone(), + source_span: elem.source_span, + }); + } + for expr in ttl.expressions.iter() { + expressions.push(convert_pure_function_body(allocator, expr, params)); + } + OutputExpression::TaggedTemplateLiteral(Box::new_in( + crate::output::ast::TaggedTemplateLiteralExpr { + tag: Box::new_in(tag, &allocator), + template: Box::new_in( + crate::output::ast::TemplateLiteralExpr { + elements, + expressions, + source_span: ttl.source_span, + }, + &allocator, + ), + source_span: ttl.source_span, + }, + &allocator, + )) + } + + // SpreadElement: convert the inner expression + IrExpression::SpreadElement(spread) => { + let inner = convert_pure_function_body(allocator, &spread.expr, params); + OutputExpression::SpreadElement(Box::new_in( + crate::output::ast::SpreadElementExpr { + expr: Box::new_in(inner, &allocator), + source_span: spread.source_span, + }, + &allocator, + )) + } + // Parenthesized expression - convert inner and wrap IrExpression::Parenthesized(paren) => { let inner = convert_pure_function_body(allocator, &paren.expr, params); diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 10d95cc2b..a84b53950 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -37,7 +37,7 @@ use crate::ir::expression::{ BinaryExpr, ConditionalCaseExpr, EmptyExpr, IrBinaryOperator, IrExpression, LexicalReadExpr, PipeBindingExpr, ResolvedCallExpr, ResolvedKeyedReadExpr, ResolvedPropertyReadExpr, SafeInvokeFunctionExpr, SafeKeyedReadExpr, SafePropertyReadExpr, SlotHandle, - TwoWayBindingSetExpr, + TwoWayBindingSetExpr, VisitorContextFlag, transform_expressions_in_expression, }; use crate::ir::ops::{ BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, CreateOp, @@ -47,9 +47,10 @@ use crate::ir::ops::{ RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp, TemplateOp, TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId, }; -use crate::output::ast::OutputExpression; +use crate::output::ast::{OutputExpression, ReadVarExpr}; use crate::pipeline::compilation::{AliasVariable, ContextVariable}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::cell::RefCell; /// Options for ingesting a component template. /// @@ -630,11 +631,118 @@ fn convert_ast_to_ir<'a>( ) } + // Convert ArrowFunction - recursively convert the body so the names it reads are + // resolved, then keep reads of the arrow's own parameters as plain variables. + AngularExpression::ArrowFunction(arrow) => { + let arrow = arrow.unbox(); + let source_span = Some(arrow.source_span.to_span()); + let mut params = Vec::with_capacity_in(arrow.parameters.len(), &allocator); + for param in &arrow.parameters { + params.push(crate::output::ast::FnParam { name: param.name }); + } + let body = convert_ast_to_ir(job, arrow.body); + let mut expr = IrExpression::ArrowFunction(Box::new_in( + crate::ir::expression::ArrowFunctionExpr { + params, + body, + ops: Vec::new_in(&allocator), + var_offset: None, + source_span, + }, + &allocator, + )); + update_parameter_references(&mut expr, allocator); + Box::new_in(expr, &allocator) + } + + // Convert TaggedTemplateLiteral - recursively convert the tag and template + // expressions to preserve pipes and arrow functions (ingest.ts convertAst). + AngularExpression::TaggedTemplateLiteral(ttl) => { + let ttl = ttl.unbox(); + let tag = convert_ast_to_ir(job, ttl.tag); + let mut elements = Vec::with_capacity_in(ttl.template.elements.len(), &allocator); + for elem in ttl.template.elements.iter() { + elements.push(crate::ir::expression::IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: Some(elem.source_span.to_span()), + }); + } + let mut expressions = Vec::with_capacity_in(ttl.template.expressions.len(), &allocator); + for expr in ttl.template.expressions { + let converted = convert_ast_to_ir(job, expr); + expressions.push(converted.unbox()); + } + Box::new_in( + IrExpression::TaggedTemplateLiteral(Box::new_in( + crate::ir::expression::IrTaggedTemplateLiteralExpr { + tag, + elements, + expressions, + source_span: Some(ttl.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Convert SpreadElement - recursively convert the inner expression so arrows + // and pipes inside spread positions (e.g. call arguments) are preserved + // (ingest.ts convertAst produces o.SpreadElementExpr). + AngularExpression::SpreadElement(spread) => { + let spread = spread.unbox(); + let expr = convert_ast_to_ir(job, spread.expression); + Box::new_in( + IrExpression::SpreadElement(Box::new_in( + crate::ir::expression::IrSpreadElementExpr { + expr, + source_span: Some(spread.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + // For all other expressions, store in ExpressionStore and return reference. other => store_and_ref_expr(job, other), } } +/// Replaces reads of an arrow function's parameters with plain variable reads, so the +/// name resolution phases leave them alone instead of resolving them against template +/// variables or the component context. +/// +/// Like Angular, the parameter set only grows: parameters of a nested arrow are added +/// when that arrow is visited (after its body), and are not removed afterwards. +/// +/// Ported from Angular's `updateParameterReferences` in `ingest.ts`. +fn update_parameter_references<'a>(root: &mut IrExpression<'a>, allocator: &'a Allocator) { + let parameter_names: RefCell>> = RefCell::new(FxHashSet::default()); + if let IrExpression::ArrowFunction(arrow) = &*root { + parameter_names.borrow_mut().extend(arrow.params.iter().map(|p| p.name)); + } + transform_expressions_in_expression( + root, + &|expr, _flags| match expr { + IrExpression::ArrowFunction(arrow) => { + parameter_names.borrow_mut().extend(arrow.params.iter().map(|p| p.name)); + } + IrExpression::LexicalRead(read) if parameter_names.borrow().contains(&read.name) => { + *expr = IrExpression::OutputExpr(Box::new_in( + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: read.name, source_span: read.source_span }, + &allocator, + )), + &allocator, + )); + } + _ => {} + }, + VisitorContextFlag::NONE, + ); +} + /// Converts an AST binary operator to an IR binary operator. fn convert_binary_op( op: crate::ast::expression::BinaryOperator, @@ -3844,6 +3952,192 @@ fn host_convert_ast_to_ir<'a>( ) } + // Convert LiteralArray - recursively convert elements to preserve pipes. + // Spread elements (e.g. [...base, item]) are preserved via the spreads parallel vec. + AngularExpression::LiteralArray(arr) => { + let arr = arr.unbox(); + let mut elements = Vec::with_capacity_in(arr.expressions.len(), &allocator); + let mut spreads = Vec::with_capacity_in(arr.expressions.len(), &allocator); + for elem in arr.expressions { + let is_spread = matches!(elem, AngularExpression::SpreadElement(_)); + let inner = if let AngularExpression::SpreadElement(s) = elem { + host_convert_ast_to_ir(job, s.unbox().expression) + } else { + host_convert_ast_to_ir(job, elem) + }; + elements.push(inner.unbox()); + spreads.push(is_spread); + } + Box::new_in( + IrExpression::LiteralArray(Box::new_in( + crate::ir::expression::IrLiteralArrayExpr { + elements, + spreads, + source_span: Some(arr.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Convert LiteralMap (object literal) - recursively convert values to preserve pipes. + // Spread entries (e.g. { ...base, key: val }) are preserved: spread keys get a dummy + // empty Ident with spreads[i] = true so later phases can emit them correctly. + AngularExpression::LiteralMap(map) => { + use crate::ast::expression::LiteralMapKey; + let map = map.unbox(); + let mut keys = Vec::with_capacity_in(map.keys.len(), &allocator); + let mut values = Vec::with_capacity_in(map.values.len(), &allocator); + let mut quoted = Vec::with_capacity_in(map.keys.len(), &allocator); + let mut spreads = Vec::with_capacity_in(map.keys.len(), &allocator); + + for (key, value) in map.keys.into_iter().zip(map.values.into_iter()) { + match key { + LiteralMapKey::Property(prop) => { + keys.push(prop.key); + quoted.push(prop.quoted); + spreads.push(false); + } + LiteralMapKey::Spread(_) => { + keys.push(Ident::from("")); + quoted.push(false); + spreads.push(true); + } + } + let value_expr = host_convert_ast_to_ir(job, value); + values.push(value_expr.unbox()); + } + + Box::new_in( + IrExpression::LiteralMap(Box::new_in( + crate::ir::expression::IrLiteralMapExpr { + keys, + values, + quoted, + spreads, + source_span: Some(map.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Convert TemplateLiteral - recursively convert inner expressions to preserve pipes + // and arrow functions, matching the component-side converter. + AngularExpression::TemplateLiteral(tl) => { + let tl = tl.unbox(); + let mut elements = Vec::with_capacity_in(tl.elements.len(), &allocator); + for elem in tl.elements.iter() { + elements.push(crate::ir::expression::IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: Some(elem.source_span.to_span()), + }); + } + let mut expressions = Vec::with_capacity_in(tl.expressions.len(), &allocator); + for expr in tl.expressions { + let converted = host_convert_ast_to_ir(job, expr); + expressions.push(converted.unbox()); + } + Box::new_in( + IrExpression::ResolvedTemplateLiteral(Box::new_in( + crate::ir::expression::ResolvedTemplateLiteralExpr { + elements, + expressions, + source_span: Some(tl.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Empty expression - convert directly to IrExpression::Empty, matching the + // component-side converter. + AngularExpression::Empty(empty) => { + let empty = empty.unbox(); + Box::new_in( + IrExpression::Empty(Box::new_in( + EmptyExpr { source_span: Some(empty.source_span.to_span()) }, + &allocator, + )), + &allocator, + ) + } + + // Convert TaggedTemplateLiteral - recursively convert the tag and template + // expressions to preserve pipes and arrow functions (ingest.ts convertAst). + AngularExpression::TaggedTemplateLiteral(ttl) => { + let ttl = ttl.unbox(); + let tag = host_convert_ast_to_ir(job, ttl.tag); + let mut elements = Vec::with_capacity_in(ttl.template.elements.len(), &allocator); + for elem in ttl.template.elements.iter() { + elements.push(crate::ir::expression::IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: Some(elem.source_span.to_span()), + }); + } + let mut expressions = Vec::with_capacity_in(ttl.template.expressions.len(), &allocator); + for expr in ttl.template.expressions { + let converted = host_convert_ast_to_ir(job, expr); + expressions.push(converted.unbox()); + } + Box::new_in( + IrExpression::TaggedTemplateLiteral(Box::new_in( + crate::ir::expression::IrTaggedTemplateLiteralExpr { + tag, + elements, + expressions, + source_span: Some(ttl.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Convert SpreadElement - recursively convert the inner expression so arrows + // and pipes inside spread positions (e.g. call arguments) are preserved. + AngularExpression::SpreadElement(spread) => { + let spread = spread.unbox(); + let expr = host_convert_ast_to_ir(job, spread.expression); + Box::new_in( + IrExpression::SpreadElement(Box::new_in( + crate::ir::expression::IrSpreadElementExpr { + expr, + source_span: Some(spread.source_span.to_span()), + }, + &allocator, + )), + &allocator, + ) + } + + // Convert ArrowFunction - recursively convert the body so the names it reads are + // resolved, then keep reads of the arrow's own parameters as plain variables. + AngularExpression::ArrowFunction(arrow) => { + let arrow = arrow.unbox(); + let source_span = Some(arrow.source_span.to_span()); + let mut params = Vec::with_capacity_in(arrow.parameters.len(), &allocator); + for param in &arrow.parameters { + params.push(crate::output::ast::FnParam { name: param.name }); + } + let body = host_convert_ast_to_ir(job, arrow.body); + let mut expr = IrExpression::ArrowFunction(Box::new_in( + crate::ir::expression::ArrowFunctionExpr { + params, + body, + ops: Vec::new_in(&allocator), + var_offset: None, + source_span, + }, + &allocator, + )); + update_parameter_references(&mut expr, allocator); + Box::new_in(expr, &allocator) + } + // For all other expressions, store in ExpressionStore and return reference other => host_store_and_ref_expr(job, other), } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs b/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs index 3c47eaf0b..158f0e3b7 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/generate_advance.rs @@ -434,6 +434,18 @@ fn get_slot_dependency_from_ir_expr(expr: &IrExpression<'_>) -> Option { } None } + IrExpression::TaggedTemplateLiteral(ttl) => { + if let Some(target) = get_slot_dependency_from_ir_expr(&ttl.tag) { + return Some(target); + } + for expr in ttl.expressions.iter() { + if let Some(target) = get_slot_dependency_from_ir_expr(expr) { + return Some(target); + } + } + None + } + IrExpression::SpreadElement(spread) => get_slot_dependency_from_ir_expr(&spread.expr), // Expressions that don't contain slot dependencies IrExpression::LexicalRead(_) | IrExpression::Context(_) diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs b/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs index 0744db68d..2c1db128e 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/generate_arrow_functions.rs @@ -173,28 +173,65 @@ pub fn generate_arrow_functions_for_host(job: &mut HostBindingCompilationJob<'_> VisitorContextFlag::NONE, ); } + + // Collect arrow functions into the root unit's functions set + let ptrs = collect_arrow_function_ptrs(&job.root.create, &job.root.update); + for ptr in ptrs { + // SAFETY: see collect_arrow_functions_from_view. + unsafe { (*ptr).var_offset = Some(u32::MAX) }; + job.root.functions.push(ptr); + } } /// Collect arrow functions from a view's operations into its functions set. +/// +/// Only top-level arrows are collected: arrows inside child operations (listener +/// handlers, nested arrow bodies) are skipped, matching upstream where +/// `unit.functions` only holds `ir.ArrowFunctionExpr`s created by +/// `addArrowFunctions` for non-listener, non-nested positions. fn collect_arrow_functions_from_view( view: &mut crate::pipeline::compilation::ViewCompilationUnit<'_>, ) { // Clear existing functions view.functions.clear(); + let ptrs = collect_arrow_function_ptrs(&view.create, &view.update); + for ptr in ptrs { + // SAFETY: The pointer targets an ArrowFunctionExpr allocated in this + // job's allocator, which outlives the pipeline. + unsafe { (*ptr).var_offset = Some(u32::MAX) }; + view.functions.push(ptr); + } +} + +/// Collect arrow function pointers from create and update op lists. +/// +/// Only top-level arrows are collected: arrows inside child operations (listener +/// handlers, nested arrow bodies) are skipped, matching upstream where +/// `unit.functions` only holds `ir.ArrowFunctionExpr`s created by +/// `addArrowFunctions` for non-listener, non-nested positions. Collected arrows +/// are marked with a sentinel var_offset (u32::MAX) so var_counting can tell +/// them apart from arrows preserved in place. +fn collect_arrow_function_ptrs<'a>( + create: &crate::ir::list::CreateOpList<'a>, + update: &crate::ir::list::UpdateOpList<'a>, +) -> std::vec::Vec<*mut ArrowFunctionExpr<'a>> { // We use RefCell to allow mutable access from within the visitor closure use std::cell::RefCell; - let collected: RefCell>> = + let collected: RefCell>> = RefCell::new(std::vec::Vec::new()); // Collect from create ops - for op in view.create.iter() { + for op in create.iter() { crate::ir::expression::visit_expressions_in_create_op( op, - &|expr, _flags| { + &|expr, flags| { + if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { + return; + } if let IrExpression::ArrowFunction(arrow_fn) = expr { - let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'_> - as *mut ArrowFunctionExpr<'_>; + let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'a> + as *mut ArrowFunctionExpr<'a>; collected.borrow_mut().push(ptr); } }, @@ -203,13 +240,16 @@ fn collect_arrow_functions_from_view( } // Collect from update ops - for op in view.update.iter() { + for op in update.iter() { crate::ir::expression::visit_expressions_in_update_op( op, - &|expr, _flags| { + &|expr, flags| { + if flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) { + return; + } if let IrExpression::ArrowFunction(arrow_fn) = expr { - let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'_> - as *mut ArrowFunctionExpr<'_>; + let ptr = arrow_fn.as_ref() as *const ArrowFunctionExpr<'a> + as *mut ArrowFunctionExpr<'a>; collected.borrow_mut().push(ptr); } }, @@ -217,9 +257,5 @@ fn collect_arrow_functions_from_view( ); } - // Move collected pointers into the allocator Vec - let ptrs = collected.into_inner(); - for ptr in ptrs { - view.functions.push(ptr); - } + collected.into_inner() } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs b/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs index 05cefd2b2..473dff783 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/next_context_merging.rs @@ -503,6 +503,26 @@ fn visit_nested_exprs_for_merge(expr: &mut IrExpression<'_>, merge_steps: u32) - MergeResult::Continue } + // TaggedTemplateLiteral: recurse into tag and embedded expressions + IrExpression::TaggedTemplateLiteral(ttl) => { + let result = visit_ir_expr_for_merge(&mut ttl.tag, merge_steps); + if result != MergeResult::Continue { + return result; + } + for e in ttl.expressions.iter_mut() { + let result = visit_ir_expr_for_merge(e, merge_steps); + if result != MergeResult::Continue { + return result; + } + } + MergeResult::Continue + } + + // SpreadElement: recurse into the inner expression + IrExpression::SpreadElement(spread) => { + visit_ir_expr_for_merge(&mut spread.expr, merge_steps) + } + // Leaf expressions with no sub-expressions - no need to recurse IrExpression::LexicalRead(_) | IrExpression::Context(_) diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs index 8b0c603ce..a336806d1 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs @@ -7,9 +7,10 @@ use crate::ast::expression::AngularExpression; use crate::ir::expression::{IrExpression, TwoWayBindingSetExpr}; use crate::ir::ops::XrefId; use crate::output::ast::{ - BinaryOperator, BinaryOperatorExpr, ConditionalExpr, InvokeFunctionExpr, LiteralArrayExpr, - LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, - ParenthesizedExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, SpreadElementExpr, + ArrowFunctionBody, ArrowFunctionExpr, BinaryOperator, BinaryOperatorExpr, ConditionalExpr, + FnParam, InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, + LiteralValue, OutputExpression, ParenthesizedExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, + SpreadElementExpr, }; use crate::pipeline::expression_store::ExpressionStore; use crate::r3::{Identifiers, get_pipe_bind_instruction, get_pure_function_instruction}; @@ -1161,6 +1162,63 @@ pub fn convert_ir_expression<'a>( )) } + // Tagged template literal: convert tag and template expressions + IrExpression::TaggedTemplateLiteral(ttl) => { + let tag = + convert_ir_expression(allocator, core_namespace, &ttl.tag, expressions, root_xref); + let mut elements = OxcVec::new_in(&allocator); + let mut output_expressions = OxcVec::new_in(&allocator); + for elem in ttl.elements.iter() { + elements.push(crate::output::ast::TemplateLiteralElement { + text: elem.text.clone(), + raw_text: elem.text.clone(), + source_span: elem.source_span, + }); + } + for expr in ttl.expressions.iter() { + output_expressions.push(convert_ir_expression( + &allocator, + core_namespace, + expr, + expressions, + root_xref, + )); + } + OutputExpression::TaggedTemplateLiteral(Box::new_in( + crate::output::ast::TaggedTemplateLiteralExpr { + tag: Box::new_in(tag, &allocator), + template: Box::new_in( + crate::output::ast::TemplateLiteralExpr { + elements, + expressions: output_expressions, + source_span: ttl.source_span, + }, + &allocator, + ), + source_span: ttl.source_span, + }, + &allocator, + )) + } + + // Spread element: convert the inner expression + IrExpression::SpreadElement(spread) => { + let inner = convert_ir_expression( + allocator, + core_namespace, + &spread.expr, + expressions, + root_xref, + ); + OutputExpression::SpreadElement(Box::new_in( + SpreadElementExpr { + expr: Box::new_in(inner, &allocator), + source_span: spread.source_span, + }, + &allocator, + )) + } + IrExpression::Parenthesized(paren) => { let inner = convert_ir_expression( allocator, @@ -1178,6 +1236,29 @@ pub fn convert_ir_expression<'a>( )) } + // Arrow function left in place: emit it with its converted body. + IrExpression::ArrowFunction(arrow_fn) => { + let mut params = OxcVec::with_capacity_in(arrow_fn.params.len(), &allocator); + for param in &arrow_fn.params { + params.push(FnParam { name: param.name }); + } + let body = convert_ir_expression( + allocator, + core_namespace, + &arrow_fn.body, + expressions, + root_xref, + ); + OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { + params, + body: ArrowFunctionBody::Expression(Box::new_in(body, &allocator)), + source_span: arrow_fn.source_span, + }, + &allocator, + )) + } + // For any remaining IR expressions, return null placeholder _ => OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::Null, source_span: None }, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_dollar_event.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_dollar_event.rs index e36fff975..6e9b69aed 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_dollar_event.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_dollar_event.rs @@ -18,40 +18,52 @@ const DOLLAR_EVENT: &str = "$event"; /// Resolves $event to the event parameter in handlers. /// /// This phase: -/// 1. Finds all `LexicalReadExpr('$event')` in event handlers +/// 1. Rewrites `LexicalReadExpr('$event')` in event handlers to an output +/// `ReadVarExpr('$event')` so it bypasses name resolution, matching the +/// TypeScript implementation. /// 2. Marks the listener as consuming $event /// -/// Note: Unlike the TypeScript implementation, we don't need to transform -/// the expression since LexicalRead will be converted to proper variable -/// access in the reify phase. +/// Expressions stored as raw AST (Ast/ExpressionRef) cannot be rewritten here; +/// they are still emitted correctly by the reify phase's `$event` special-case. pub fn resolve_dollar_event(job: &mut ComponentCompilationJob<'_>) { // We need to borrow the expression store separately from the views // to avoid mutable borrow conflicts. We use a raw pointer to the store // since transform_dollar_event_create only needs read access to expressions. let expressions_ptr = &job.expressions as *const ExpressionStore<'_>; + let allocator = job.allocator; // Process each view's create ops (listeners are CreateOps) for view in job.all_views_mut() { // SAFETY: We only read from expressions, never modify it, and job.expressions // outlives this function call. let expressions = unsafe { &*expressions_ptr }; - transform_dollar_event_create(&mut view.create, expressions); + transform_dollar_event_create(&mut view.create, expressions, allocator); } } /// Transform $event in create operations. +/// +/// Listener handler ops are transformed as child operations, matching +/// `transformExpressionsInOp`. Each `LexicalReadExpr('$event')` found is +/// rewritten to an output `ReadVarExpr`, like Angular's resolveDollarEvent. fn transform_dollar_event_create<'a>( ops: &mut crate::ir::list::CreateOpList<'a>, expressions: &ExpressionStore<'a>, + allocator: &'a oxc_allocator::Allocator, ) { for op in ops.iter_mut() { match op { CreateOp::Listener(listener) => { // Check handler expression for $event usage - if let Some(handler) = &listener.handler_expression { + if let Some(handler) = &mut listener.handler_expression { if expression_contains_dollar_event(handler, expressions) { listener.consumes_dollar_event = true; } + crate::ir::expression::transform_expressions_in_expression( + handler, + &|expr, _flags| replace_dollar_event(expr, allocator), + VisitorContextFlag::IN_CHILD_OPERATION, + ); } // Check handler ops for $event usage @@ -64,32 +76,29 @@ fn transform_dollar_event_create<'a>( found.set(true); } }, - VisitorContextFlag::NONE, + VisitorContextFlag::IN_CHILD_OPERATION, ); if found.get() { listener.consumes_dollar_event = true; - break; } } + // Rewrite $event reads in handler ops + for handler_op in listener.handler_ops.iter_mut() { + transform_expressions_in_update_op( + handler_op, + &|expr, _flags| replace_dollar_event(expr, allocator), + VisitorContextFlag::IN_CHILD_OPERATION, + ); + } } CreateOp::TwoWayListener(listener) => { // Check handler ops for $event usage for handler_op in listener.handler_ops.iter_mut() { - let found = Cell::new(false); transform_expressions_in_update_op( handler_op, - &|expr, _flags| { - if expression_contains_dollar_event(expr, expressions) { - found.set(true); - } - }, - VisitorContextFlag::NONE, + &|expr, _flags| replace_dollar_event(expr, allocator), + VisitorContextFlag::IN_CHILD_OPERATION, ); - if found.get() { - // TwoWayListener doesn't have consumes_dollar_event field - // since it always implicitly consumes $event - break; - } } } CreateOp::AnimationListener(listener) => { @@ -103,19 +112,45 @@ fn transform_dollar_event_create<'a>( found.set(true); } }, - VisitorContextFlag::NONE, + VisitorContextFlag::IN_CHILD_OPERATION, ); if found.get() { listener.consumes_dollar_event = true; - break; } } + // Rewrite $event reads in handler ops + for handler_op in listener.handler_ops.iter_mut() { + transform_expressions_in_update_op( + handler_op, + &|expr, _flags| replace_dollar_event(expr, allocator), + VisitorContextFlag::IN_CHILD_OPERATION, + ); + } } _ => {} } } } +/// Rewrites `LexicalReadExpr('$event')` to an output `ReadVarExpr` so the read +/// bypasses name resolution, like Angular's resolveDollarEvent. +fn replace_dollar_event<'a>(expr: &mut IrExpression<'a>, allocator: &'a oxc_allocator::Allocator) { + if let IrExpression::LexicalRead(lexical) = expr { + if lexical.name.as_str() == DOLLAR_EVENT { + *expr = IrExpression::OutputExpr(oxc_allocator::Box::new_in( + crate::output::ast::OutputExpression::ReadVar(oxc_allocator::Box::new_in( + crate::output::ast::ReadVarExpr { + name: lexical.name.clone(), + source_span: lexical.source_span, + }, + &allocator, + )), + &allocator, + )); + } + } +} + /// Check if an expression contains a $event reference. fn expression_contains_dollar_event<'a>( expr: &IrExpression<'a>, @@ -262,6 +297,17 @@ fn expression_contains_dollar_event<'a>( rtl.expressions.iter().any(|e| expression_contains_dollar_event(e, expressions)) } + // TaggedTemplateLiteral: check the tag and embedded expressions + IrExpression::TaggedTemplateLiteral(ttl) => { + expression_contains_dollar_event(&ttl.tag, expressions) + || ttl.expressions.iter().any(|e| expression_contains_dollar_event(e, expressions)) + } + + // SpreadElement: check the inner expression + IrExpression::SpreadElement(spread) => { + expression_contains_dollar_event(&spread.expr, expressions) + } + // ArrowFunction: check the body expression IrExpression::ArrowFunction(arrow_fn) => { expression_contains_dollar_event(&arrow_fn.body, expressions) @@ -397,5 +443,5 @@ pub fn resolve_dollar_event_for_host(job: &mut HostBindingCompilationJob<'_>) { let expressions = unsafe { &*expressions_ptr }; // Process create ops for listeners - transform_dollar_event_create(&mut job.root.create, expressions); + transform_dollar_event_create(&mut job.root.create, expressions, job.allocator); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs index 89d4534a5..a4a95a280 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs @@ -857,6 +857,19 @@ fn resolve_expression<'a>( } } + // Arrow function - resolve the names its body reads. Reads of its own parameters + // were already turned into plain variables during ingestion. + IrExpression::ArrowFunction(arrow_fn) => { + resolve_expression( + arrow_fn.body.as_mut(), + scope, + root_xref, + saved_view, + allocator, + expressions, + ); + } + // Other expression types don't need resolution _ => {} } @@ -874,6 +887,22 @@ fn resolve_angular_expression<'a>( scope: &ScopeMaps<'a>, root_xref: XrefId, allocator: &'a oxc_allocator::Allocator, +) -> Option> { + resolve_angular_expression_with_params(ast_expr, scope, root_xref, allocator, None) +} + +/// Inner resolver that also tracks arrow function parameter names. +/// +/// `param_names` is the shared parameter set of the enclosing arrow function's +/// `updateParameterReferences` run: a `PropertyRead(ImplicitReceiver, name)` +/// whose name is in the set becomes a plain `ReadVarExpr` instead of resolving +/// against scope or the component context. `None` outside arrow subtrees. +fn resolve_angular_expression_with_params<'a>( + ast_expr: &AngularExpression<'a>, + scope: &ScopeMaps<'a>, + root_xref: XrefId, + allocator: &'a oxc_allocator::Allocator, + param_names: Option<&std::cell::RefCell>>>, ) -> Option> { match ast_expr { AngularExpression::PropertyRead(prop_read) => { @@ -882,6 +911,21 @@ fn resolve_angular_expression<'a>( let name = &prop_read.name; let source_span = Some(prop_read.source_span.to_span()); + // A read of an enclosing arrow function's own parameter becomes a plain + // variable read, matching Angular's updateParameterReferences which turns + // parameter LexicalReads into o.ReadVarExpr before name resolution. + if let Some(names) = param_names { + if names.borrow().contains(name) { + return Some(IrExpression::OutputExpr(Box::new_in( + crate::output::ast::OutputExpression::ReadVar(Box::new_in( + crate::output::ast::ReadVarExpr { name: name.clone(), source_span }, + &allocator, + )), + &allocator, + ))); + } + } + // Context properties ($implicit, $index, etc.) and $event are special: // - $event is the event handler parameter, never resolved to a variable // - Context properties may or may not be in scope: @@ -947,9 +991,13 @@ fn resolve_angular_expression<'a>( } else { // This is a nested property read like item.name // Try to resolve the receiver first - if let Some(resolved_receiver) = - resolve_angular_expression(&prop_read.receiver, scope, root_xref, &allocator) - { + if let Some(resolved_receiver) = resolve_angular_expression_with_params( + &prop_read.receiver, + scope, + root_xref, + allocator, + param_names, + ) { // Receiver was resolved - create ResolvedPropertyRead Some(IrExpression::ResolvedPropertyRead(Box::new_in( ResolvedPropertyReadExpr { @@ -977,17 +1025,26 @@ fn resolve_angular_expression<'a>( AngularExpression::Call(call) => { // Resolve the receiver - let resolved_receiver = - resolve_angular_expression(&call.receiver, scope, root_xref, &allocator); + let resolved_receiver = resolve_angular_expression_with_params( + &call.receiver, + scope, + root_xref, + allocator, + param_names, + ); // Resolve each argument let mut resolved_args = oxc_allocator::Vec::new_in(&allocator); let mut any_arg_resolved = false; for arg in call.args.iter() { - if let Some(resolved_arg) = - resolve_angular_expression(arg, scope, root_xref, &allocator) - { + if let Some(resolved_arg) = resolve_angular_expression_with_params( + arg, + scope, + root_xref, + allocator, + param_names, + ) { resolved_args.push(resolved_arg); any_arg_resolved = true; } else { @@ -1025,10 +1082,21 @@ fn resolve_angular_expression<'a>( AngularExpression::KeyedRead(keyed) => { // Resolve receiver for keyed reads like item[0] - let resolved_receiver = - resolve_angular_expression(&keyed.receiver, scope, root_xref, &allocator); + let resolved_receiver = resolve_angular_expression_with_params( + &keyed.receiver, + scope, + root_xref, + allocator, + param_names, + ); // Also try to resolve the key expression - let resolved_key = resolve_angular_expression(&keyed.key, scope, root_xref, &allocator); + let resolved_key = resolve_angular_expression_with_params( + &keyed.key, + scope, + root_xref, + allocator, + param_names, + ); if resolved_receiver.is_some() || resolved_key.is_some() { // At least one part was resolved, create a ResolvedKeyedRead @@ -1064,9 +1132,13 @@ fn resolve_angular_expression<'a>( AngularExpression::SafePropertyRead(safe) => { // Resolve receiver for safe property reads like item?.name - if let Some(resolved_receiver) = - resolve_angular_expression(&safe.receiver, scope, root_xref, &allocator) - { + if let Some(resolved_receiver) = resolve_angular_expression_with_params( + &safe.receiver, + scope, + root_xref, + allocator, + param_names, + ) { Some(IrExpression::ResolvedSafePropertyRead(Box::new_in( ResolvedSafePropertyReadExpr { receiver: Box::new_in(resolved_receiver, &allocator), @@ -1083,10 +1155,20 @@ fn resolve_angular_expression<'a>( AngularExpression::Binary(binary) => { // Handle binary expressions, especially assignments in event handlers // like `todo.done = $event` - let resolved_left = - resolve_angular_expression(&binary.left, scope, root_xref, &allocator); - let resolved_right = - resolve_angular_expression(&binary.right, scope, root_xref, &allocator); + let resolved_left = resolve_angular_expression_with_params( + &binary.left, + scope, + root_xref, + allocator, + param_names, + ); + let resolved_right = resolve_angular_expression_with_params( + &binary.right, + scope, + root_xref, + allocator, + param_names, + ); if resolved_left.is_some() || resolved_right.is_some() { // At least one side was resolved, create a ResolvedBinary @@ -1135,9 +1217,13 @@ fn resolve_angular_expression<'a>( let mut any_resolved = false; for expr in tl.expressions.iter() { - if let Some(resolved) = - resolve_angular_expression(expr, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + expr, + scope, + root_xref, + allocator, + param_names, + ) { resolved_exprs.push(resolved); any_resolved = true; } else { @@ -1174,12 +1260,27 @@ fn resolve_angular_expression<'a>( AngularExpression::Conditional(cond) => { // Handle conditional (ternary) expressions - let resolved_condition = - resolve_angular_expression(&cond.condition, scope, root_xref, &allocator); - let resolved_true = - resolve_angular_expression(&cond.true_exp, scope, root_xref, &allocator); - let resolved_false = - resolve_angular_expression(&cond.false_exp, scope, root_xref, &allocator); + let resolved_condition = resolve_angular_expression_with_params( + &cond.condition, + scope, + root_xref, + allocator, + param_names, + ); + let resolved_true = resolve_angular_expression_with_params( + &cond.true_exp, + scope, + root_xref, + allocator, + param_names, + ); + let resolved_false = resolve_angular_expression_with_params( + &cond.false_exp, + scope, + root_xref, + allocator, + param_names, + ); if resolved_condition.is_some() || resolved_true.is_some() || resolved_false.is_some() { let condition = resolved_condition.unwrap_or_else(|| { @@ -1227,9 +1328,13 @@ fn resolve_angular_expression<'a>( let mut any_resolved = false; for value in map.values.iter() { - if let Some(resolved) = - resolve_angular_expression(value, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + value, + scope, + root_xref, + allocator, + param_names, + ) { resolved_values.push(resolved); any_resolved = true; } else { @@ -1290,9 +1395,13 @@ fn resolve_angular_expression<'a>( } else { entry }; - if let Some(resolved) = - resolve_angular_expression(inner, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + inner, + scope, + root_xref, + allocator, + param_names, + ) { resolved_entries.push(resolved); any_resolved = true; } else { @@ -1321,9 +1430,13 @@ fn resolve_angular_expression<'a>( AngularExpression::PrefixNot(prefix_not) => { // Handle prefix not expressions (!expr) - need to resolve variable references // in the operand. This is critical for expressions like `!bold` in listeners. - if let Some(resolved) = - resolve_angular_expression(&prefix_not.expression, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + &prefix_not.expression, + scope, + root_xref, + allocator, + param_names, + ) { Some(IrExpression::Not(Box::new_in( crate::ir::expression::NotExpr { expr: Box::new_in(resolved, &allocator), @@ -1338,9 +1451,13 @@ fn resolve_angular_expression<'a>( AngularExpression::Unary(unary) => { // Handle unary expressions (+expr or -expr) - need to resolve variable references - if let Some(resolved) = - resolve_angular_expression(&unary.expr, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + &unary.expr, + scope, + root_xref, + allocator, + param_names, + ) { Some(IrExpression::Unary(Box::new_in( crate::ir::expression::UnaryExpr { operator: match unary.operator { @@ -1363,9 +1480,13 @@ fn resolve_angular_expression<'a>( AngularExpression::TypeofExpression(typeof_expr) => { // Handle typeof expressions - need to resolve variable references - if let Some(resolved) = - resolve_angular_expression(&typeof_expr.expression, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + &typeof_expr.expression, + scope, + root_xref, + allocator, + param_names, + ) { Some(IrExpression::Typeof(Box::new_in( crate::ir::expression::TypeofExpr { expr: Box::new_in(resolved, &allocator), @@ -1380,9 +1501,13 @@ fn resolve_angular_expression<'a>( AngularExpression::VoidExpression(void_expr) => { // Handle void expressions - need to resolve variable references - if let Some(resolved) = - resolve_angular_expression(&void_expr.expression, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + &void_expr.expression, + scope, + root_xref, + allocator, + param_names, + ) { Some(IrExpression::Void(Box::new_in( crate::ir::expression::VoidExpr { expr: Box::new_in(resolved, &allocator), @@ -1398,49 +1523,63 @@ fn resolve_angular_expression<'a>( AngularExpression::NonNullAssert(nna) => { // Handle non-null assertion expressions (expr!) - need to resolve variable references // NonNullAssert expressions are wrapped in Ast since IrExpression doesn't have this variant - resolve_angular_expression(&nna.expression, scope, root_xref, &allocator) + resolve_angular_expression_with_params( + &nna.expression, + scope, + root_xref, + allocator, + param_names, + ) } AngularExpression::ParenthesizedExpression(paren) => { // Handle parenthesized expressions - need to resolve variable references // within the inner expression - resolve_angular_expression(&paren.expression, scope, root_xref, &allocator) + resolve_angular_expression_with_params( + &paren.expression, + scope, + root_xref, + allocator, + param_names, + ) } AngularExpression::Chain(chain) => { - // Handle chain expressions (expr1; expr2; ...) - need to resolve all expressions - // Chain is not directly supported in IrExpression, so we need to handle it specially - let mut any_resolved = false; - for expr in chain.expressions.iter() { - if resolve_angular_expression(expr, scope, root_xref, &allocator).is_some() { - any_resolved = true; - break; - } - } - // Chain expressions are not representable in IR, but we still want to - // flag if any inner expressions need resolution - // For now, return None and let the Ast path handle it - if any_resolved { - // This path will not be perfectly handled, but chain expressions - // in listeners are unusual; the primary path (Ast) will handle most cases - None - } else { - None + // Chains are not directly representable in IR. The reify path emits only + // the last member of a stored chain, so resolve that member here. + match chain.expressions.last() { + Some(last) => resolve_angular_expression_with_params( + last, + scope, + root_xref, + allocator, + param_names, + ), + None => None, } } AngularExpression::SafeCall(safe_call) => { // Handle safe function calls (fn?.()) - need to resolve receiver and arguments - let resolved_receiver = - resolve_angular_expression(&safe_call.receiver, scope, root_xref, &allocator); + let resolved_receiver = resolve_angular_expression_with_params( + &safe_call.receiver, + scope, + root_xref, + allocator, + param_names, + ); let mut resolved_args = oxc_allocator::Vec::new_in(&allocator); let mut any_arg_resolved = false; for arg in safe_call.args.iter() { - if let Some(resolved) = - resolve_angular_expression(arg, scope, root_xref, &allocator) - { + if let Some(resolved) = resolve_angular_expression_with_params( + arg, + scope, + root_xref, + allocator, + param_names, + ) { resolved_args.push(resolved); any_arg_resolved = true; } else { @@ -1478,10 +1617,20 @@ fn resolve_angular_expression<'a>( AngularExpression::SafeKeyedRead(safe_keyed) => { // Handle safe keyed read (obj?.[key]) - need to resolve receiver and key - let resolved_receiver = - resolve_angular_expression(&safe_keyed.receiver, scope, root_xref, &allocator); - let resolved_key = - resolve_angular_expression(&safe_keyed.key, scope, root_xref, &allocator); + let resolved_receiver = resolve_angular_expression_with_params( + &safe_keyed.receiver, + scope, + root_xref, + allocator, + param_names, + ); + let resolved_key = resolve_angular_expression_with_params( + &safe_keyed.key, + scope, + root_xref, + allocator, + param_names, + ); if resolved_receiver.is_some() || resolved_key.is_some() { let receiver = resolved_receiver.unwrap_or_else(|| { @@ -1516,6 +1665,133 @@ fn resolve_angular_expression<'a>( } } + AngularExpression::ArrowFunction(arrow) => { + // Mirror Angular's updateParameterReferences: reads of the arrow's own + // parameters become plain variable reads. The parameter set is shared + // with the enclosing arrow's run (if any) and only grows: this arrow's + // parameters are added after its body is resolved, matching the + // post-order traversal in ingest.ts. + let names = match param_names { + Some(shared) => std::cell::RefCell::new( + shared.borrow().iter().cloned().collect::>>(), + ), + None => std::cell::RefCell::new(rustc_hash::FxHashSet::default()), + }; + names.borrow_mut().extend(arrow.parameters.iter().map(|p| p.name.clone())); + + let body = resolve_angular_expression_with_params( + &arrow.body, + scope, + root_xref, + allocator, + Some(&names), + ) + .unwrap_or_else(|| { + IrExpression::Ast(Box::new_in( + crate::ir::expression::clone_angular_expression(&arrow.body, &allocator), + &allocator, + )) + }); + + if let Some(shared) = param_names { + shared.borrow_mut().extend(names.borrow().iter().cloned()); + } + + let mut params = oxc_allocator::Vec::new_in(&allocator); + for param in arrow.parameters.iter() { + params.push(crate::output::ast::FnParam { name: param.name.clone() }); + } + + Some(IrExpression::ArrowFunction(Box::new_in( + crate::ir::expression::ArrowFunctionExpr { + params, + body: Box::new_in(body, &allocator), + ops: oxc_allocator::Vec::new_in(&allocator), + var_offset: None, + source_span: Some(arrow.source_span.to_span()), + }, + &allocator, + ))) + } + + AngularExpression::TaggedTemplateLiteral(ttl) => { + // Resolve the tag and the template's embedded expressions. + let resolved_tag = resolve_angular_expression_with_params( + &ttl.tag, + scope, + root_xref, + allocator, + param_names, + ); + + let mut resolved_exprs = oxc_allocator::Vec::new_in(&allocator); + let mut any_resolved = resolved_tag.is_some(); + for expr in ttl.template.expressions.iter() { + if let Some(resolved) = resolve_angular_expression_with_params( + expr, + scope, + root_xref, + allocator, + param_names, + ) { + resolved_exprs.push(resolved); + any_resolved = true; + } else { + resolved_exprs.push(IrExpression::Ast(Box::new_in( + crate::ir::expression::clone_angular_expression(expr, &allocator), + &allocator, + ))); + } + } + + if any_resolved { + let tag = resolved_tag.unwrap_or_else(|| { + IrExpression::Ast(Box::new_in( + crate::ir::expression::clone_angular_expression(&ttl.tag, &allocator), + &allocator, + )) + }); + let mut elements = oxc_allocator::Vec::new_in(&allocator); + for elem in ttl.template.elements.iter() { + elements.push(crate::ir::expression::IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: Some(elem.source_span.to_span()), + }); + } + Some(IrExpression::TaggedTemplateLiteral(Box::new_in( + crate::ir::expression::IrTaggedTemplateLiteralExpr { + tag: Box::new_in(tag, &allocator), + elements, + expressions: resolved_exprs, + source_span: Some(ttl.source_span.to_span()), + }, + &allocator, + ))) + } else { + None + } + } + + AngularExpression::SpreadElement(spread) => { + // Resolve the inner expression of a spread element. + resolve_angular_expression_with_params( + &spread.expression, + scope, + root_xref, + allocator, + param_names, + ) + .map(|resolved| { + IrExpression::SpreadElement(Box::new_in( + crate::ir::expression::IrSpreadElementExpr { + expr: Box::new_in(resolved, &allocator), + source_span: Some(spread.source_span.to_span()), + }, + &allocator, + )) + }) + } + // Other expression types don't need recursive resolution _ => None, } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs b/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs index f66e5d135..15c48bdd8 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/save_restore_view.rs @@ -452,12 +452,13 @@ fn add_restore_view_to_listener<'a>( handler_ops.insert(0, restore_var); // Wrap handler_expression in ResetViewExpr (Angular's save_restore_view.ts lines 84-91) - // This resets the view context after the listener handler returns + // This resets the view context after the listener handler returns. The expression + // is moved in place (not cloned) so nodes referenced elsewhere, such as arrow + // functions tracked in view.functions, keep their identity. if let Some(expr) = handler_expression.take() { - let cloned_expr = expr.clone_in(allocator); *handler_expression = Some(Box::new_in( IrExpression::ResetView(Box::new_in( - ResetViewExpr { expr: Box::new_in(cloned_expr, &allocator), source_span: None }, + ResetViewExpr { expr, source_span: None }, &allocator, )), &allocator, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/store_let_optimization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/store_let_optimization.rs index 2cfa32216..c4e9425e9 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/store_let_optimization.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/store_let_optimization.rs @@ -682,6 +682,30 @@ fn transform_nested_expressions<'a>( ); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + transform_store_let_in_expr_value( + &allocator, + &mut ttl.tag, + let_used_externally, + declare_lets_to_remove, + ); + for e in ttl.expressions.iter_mut() { + transform_store_let_in_expr_value( + &allocator, + e, + let_used_externally, + declare_lets_to_remove, + ); + } + } + IrExpression::SpreadElement(spread) => { + transform_store_let_in_expr_value( + &allocator, + &mut spread.expr, + let_used_externally, + declare_lets_to_remove, + ); + } IrExpression::ArrowFunction(arrow_fn) => { transform_store_let_in_expr_value( @@ -1084,6 +1108,30 @@ fn transform_nested_in_expr_value<'a>( ); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + transform_store_let_in_expr_value( + &allocator, + &mut ttl.tag, + let_used_externally, + declare_lets_to_remove, + ); + for e in ttl.expressions.iter_mut() { + transform_store_let_in_expr_value( + &allocator, + e, + let_used_externally, + declare_lets_to_remove, + ); + } + } + IrExpression::SpreadElement(spread) => { + transform_store_let_in_expr_value( + &allocator, + &mut spread.expr, + let_used_externally, + declare_lets_to_remove, + ); + } IrExpression::ArrowFunction(arrow_fn) => { transform_store_let_in_expr_value( @@ -1152,6 +1200,10 @@ fn has_pipe(expr: &IrExpression<'_>) -> bool { IrExpression::Typeof(t) => has_pipe(&t.expr), IrExpression::Void(v) => has_pipe(&v.expr), IrExpression::ResolvedTemplateLiteral(rtl) => rtl.expressions.iter().any(has_pipe), + IrExpression::TaggedTemplateLiteral(ttl) => { + has_pipe(&ttl.tag) || ttl.expressions.iter().any(has_pipe) + } + IrExpression::SpreadElement(spread) => has_pipe(&spread.expr), // Leaf expressions don't contain pipes IrExpression::LexicalRead(_) | IrExpression::Reference(_) @@ -1398,6 +1450,15 @@ fn collect_context_let_refs_in_expr(expr: &IrExpression<'_>, refs: &mut HashSet< collect_context_let_refs_in_expr(e, refs); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + collect_context_let_refs_in_expr(&ttl.tag, refs); + for e in ttl.expressions.iter() { + collect_context_let_refs_in_expr(e, refs); + } + } + IrExpression::SpreadElement(spread) => { + collect_context_let_refs_in_expr(&spread.expr, refs); + } IrExpression::ArrowFunction(arrow_fn) => { collect_context_let_refs_in_expr(&arrow_fn.body, refs); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs b/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs index 8a3476cd2..5f6914189 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/strip_nonrequired_parentheses.rs @@ -397,6 +397,15 @@ fn check_ir_expression_for_required_parens( check_ir_expression_for_required_parens(e, required); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + check_ir_expression_for_required_parens(&ttl.tag, required); + for e in ttl.expressions.iter() { + check_ir_expression_for_required_parens(e, required); + } + } + IrExpression::SpreadElement(spread) => { + check_ir_expression_for_required_parens(&spread.expr, required); + } IrExpression::ArrowFunction(arrow_fn) => { check_ir_expression_for_required_parens(&arrow_fn.body, required); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs b/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs index becb984bc..f37195191 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/temporary_variables.rs @@ -573,6 +573,15 @@ fn assign_temp_names<'a>( assign_temp_names(e, tracker, &allocator); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + assign_temp_names(&mut ttl.tag, tracker, &allocator); + for e in ttl.expressions.iter_mut() { + assign_temp_names(e, tracker, &allocator); + } + } + IrExpression::SpreadElement(spread) => { + assign_temp_names(&mut spread.expr, tracker, &allocator); + } IrExpression::ArrowFunction(arrow_fn) => { assign_temp_names(&mut arrow_fn.body, tracker, &allocator); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs b/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs index d25afc0af..e5cf59681 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs @@ -641,15 +641,32 @@ fn assign_var_offsets_in_expr( assign_var_offsets_in_expr(e, var_count, pure_functions_only); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + assign_var_offsets_in_expr(&mut ttl.tag, var_count, pure_functions_only); + for e in ttl.expressions.iter_mut() { + assign_var_offsets_in_expr(e, var_count, pure_functions_only); + } + } + IrExpression::SpreadElement(spread) => { + assign_var_offsets_in_expr(&mut spread.expr, var_count, pure_functions_only); + } IrExpression::ArrowFunction(arrow_fn) => { - // Arrow functions consume a var slot and have their own var_offset - if !pure_functions_only { + // Collected arrows (members of unit.functions, marked by + // generate_arrow_functions with a u32::MAX sentinel) consume a var + // slot upstream (var_counting.ts varsUsedByIrExpression), even though + // they are emitted in place here since `ɵɵarrowFunction` hoisting is + // not implemented. Arrows preserved in place (var_offset still None) + // consume nothing. Process ops and body for inner var consumers. + let is_collected = arrow_fn.var_offset == Some(u32::MAX); + for op in arrow_fn.ops.iter_mut() { + assign_var_offsets_in_op(op, var_count, pure_functions_only); + } + assign_var_offsets_in_expr(&mut arrow_fn.body, var_count, pure_functions_only); + if is_collected && !pure_functions_only { arrow_fn.var_offset = Some(*var_count); *var_count += 1; } - // Process the body expression - assign_var_offsets_in_expr(&mut arrow_fn.body, var_count, pure_functions_only); } IrExpression::Parenthesized(paren) => { assign_var_offsets_in_expr(&mut paren.expr, var_count, pure_functions_only); diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs index 22649574f..1d70a6852 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/variable_optimization.rs @@ -229,6 +229,15 @@ fn collect_fences(expr: &IrExpression<'_>) -> Fence { fences |= collect_fences(e); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + fences |= collect_fences(&ttl.tag); + for e in ttl.expressions.iter() { + fences |= collect_fences(e); + } + } + IrExpression::SpreadElement(spread) => { + fences |= collect_fences(&spread.expr); + } IrExpression::ArrowFunction(arrow_fn) => { fences |= collect_fences(&arrow_fn.body); @@ -2691,6 +2700,15 @@ fn collect_variable_xrefs(expr: &IrExpression<'_>, xrefs: &mut Vec) { collect_variable_xrefs(e, xrefs); } } + IrExpression::TaggedTemplateLiteral(ttl) => { + collect_variable_xrefs(&ttl.tag, xrefs); + for e in ttl.expressions.iter() { + collect_variable_xrefs(e, xrefs); + } + } + IrExpression::SpreadElement(spread) => { + collect_variable_xrefs(&spread.expr, xrefs); + } IrExpression::ArrowFunction(arrow_fn) => { collect_variable_xrefs(&arrow_fn.body, xrefs); @@ -3749,6 +3767,43 @@ where &allocator, )) } + IrExpression::TaggedTemplateLiteral(ttl) => { + use crate::ir::expression::{IrTaggedTemplateLiteralExpr, IrTemplateLiteralElement}; + let tag = transform_expression(&ttl.tag, &allocator, transform); + let mut elements = OxcVec::with_capacity_in(ttl.elements.len(), &allocator); + for elem in ttl.elements.iter() { + elements.push(IrTemplateLiteralElement { + text: elem.text.clone(), + source_span: elem.source_span, + }); + } + let mut expressions = OxcVec::with_capacity_in(ttl.expressions.len(), &allocator); + for e in ttl.expressions.iter() { + expressions.push(transform_expression(e, &allocator, transform)); + } + IrExpression::TaggedTemplateLiteral(OxcBox::new_in( + IrTaggedTemplateLiteralExpr { + tag: OxcBox::new_in(tag, &allocator), + elements, + expressions, + source_span: ttl.source_span, + }, + &allocator, + )) + } + IrExpression::SpreadElement(spread) => { + use crate::ir::expression::IrSpreadElementExpr; + IrExpression::SpreadElement(OxcBox::new_in( + IrSpreadElementExpr { + expr: OxcBox::new_in( + transform_expression(&spread.expr, &allocator, transform), + &allocator, + ), + source_span: spread.source_span, + }, + &allocator, + )) + } IrExpression::ArrowFunction(arrow_fn) => { use crate::ir::expression::ArrowFunctionExpr; diff --git a/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs new file mode 100644 index 000000000..c7053caa7 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/arrow_function_scope_test.rs @@ -0,0 +1,255 @@ +//! A name read inside a template arrow function resolves to the arrow's own +//! parameter (or an enclosing arrow's) before template variables and the +//! component context: `open.update((o) => !o)` must not emit `(o) => !ctx.o`. +//! +//! Ported from Angular's `updateParameterReferences` in +//! `template/pipeline/src/ingest.ts`. The expected strings are ngtsc's output +//! for the same templates, in this emitter's spelling (parenthesized params +//! and binary operands). + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + CompilationMode, TransformOptions, + output::emitter::JsEmitter, + parser::html::HtmlParser, + pipeline::{emit::compile_template, ingest::ingest_component}, + transform::html_to_r3::{HtmlToR3Transform, TransformOptions as HtmlTransformOptions}, + transform_angular_file, +}; +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 listener_arrow_reads_its_parameter() { + assert_emits( + r#""#, + "returnctx.open.update((o)=>!o);", + ); +} + +#[test] +fn listener_arrow_without_parameters_reads_the_component() { + assert_emits( + r#""#, + "returnctx.open.update(()=>!ctx.open());", + ); +} + +#[test] +fn listener_arrow_reads_several_parameters() { + assert_emits( + r#""#, + "returnctx.run((a,b)=>(a+b));", + ); +} + +#[test] +fn parameter_wins_over_a_component_property_of_the_same_name() { + assert_emits( + r#""#, + "returnctx.open.update((open)=>!open);", + ); +} + +#[test] +fn listener_arrow_reads_a_parameter_and_a_component_property() { + assert_emits( + r#""#, + "returnctx.open.update((o)=>(o||ctx.fallback()));", + ); +} + +#[test] +fn nested_arrow_reads_the_outer_parameter() { + assert_emits( + r#""#, + "returnctx.run((a)=>(b)=>((a+b)+ctx.other));", + ); +} + +#[test] +fn parameter_shadows_a_let_declaration() { + assert_emits( + r#"@let v = 1; {{ v }}"#, + "returnctx.run((v)=>(v+ctx.other));", + ); +} + +#[test] +fn parameter_shadows_a_for_item() { + assert_emits( + r#"@for (item of items; track item) { }"#, + "i0.ɵɵrestoreView(_r1);constctx_r1=i0.ɵɵnextContext();\ + returni0.ɵɵresetView(ctx_r1.run((item)=>(item+ctx_r1.other)));", + ); +} + +#[test] +fn parameter_shadows_a_template_reference() { + assert_emits( + r#""#, + "returnctx.run((el)=>(el+ctx.other));", + ); +} + +#[test] +fn listener_arrow_reads_template_variables_that_are_not_parameters() { + assert_emits( + r#"@for (item of items; track item) { }"#, + "constitem_r2=i0.ɵɵrestoreView(_r1).$implicit;constctx_r2=i0.ɵɵnextContext();\ + constel_r4=i0.ɵɵreference(1);\ + returni0.ɵɵresetView(ctx_r2.run((x)=>(((x+item_r2)+el_r4.value)+ctx_r2.other)));", + ); +} + +#[test] +fn listener_arrow_reads_dollar_event() { + assert_emits( + r#""#, + "listener($event){returnctx.run((x)=>(x+$event.type));}", + ); +} + +/// ngtsc adds a nested arrow's parameters to the enclosing arrow's set once it has +/// visited that nested arrow, so a later read of the same name is left as a plain +/// variable while an earlier one still reads the component. +#[test] +fn nested_arrow_parameters_leak_to_later_reads_like_ngtsc() { + assert_emits( + r#""#, + "returnctx.run((a)=>(ctx.f((b)=>b)+b));", + ); + assert_emits( + r#""#, + "returnctx.run((a)=>(ctx.b+ctx.f((b)=>b)));", + ); +} + +// Outside listeners ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not +// implemented here: the arrow is emitted in place. Its body must still match the +// body of ngtsc's hoisted function. + +#[test] +fn property_binding_arrow_reads_its_parameter() { + assert_emits( + r#"
"#, + r#"i0.ɵɵproperty("title",ctx.run((o)=>!o));"#, + ); + assert_emits( + r#"
"#, + r#"i0.ɵɵproperty("title",ctx.run((open)=>!open));"#, + ); + assert_emits( + r#"
"#, + r#"i0.ɵɵproperty("title",ctx.run((a)=>(b)=>((a+b)+ctx.other)));"#, + ); +} + +#[test] +fn property_binding_arrow_without_parameters_reads_the_component() { + assert_emits( + r#"
"#, + r#"i0.ɵɵproperty("title",ctx.run(()=>!ctx.open()));"#, + ); +} + +#[test] +fn property_binding_parameter_shadows_template_variables() { + assert_emits( + r#"@let v = 1;
{{ v }}"#, + r#"i0.ɵɵproperty("title",ctx.run((v)=>(v+ctx.other)));"#, + ); + assert_emits( + r#"@for (item of items; track item) {
}"#, + r#"i0.ɵɵproperty("title",ctx_r0.run((item)=>(item+ctx_r0.other)));"#, + ); + assert_emits( + r#"
"#, + r#"i0.ɵɵproperty("title",ctx.run((el)=>(el+ctx.other)));"#, + ); +} + +#[test] +fn property_binding_arrow_reads_template_variables_that_are_not_parameters() { + assert_emits( + r#"@for (item of items; track item) {
}"#, + "constitem_r1=ctx.$implicit;constctx_r1=i0.ɵɵnextContext();constel_r3=i0.ɵɵreference(1);", + ); + assert_emits( + r#"@for (item of items; track item) {
}"#, + "ctx_r1.run((x)=>(((x+item_r1)+el_r3.value)+ctx_r1.other))", + ); +} + +#[test] +fn interpolation_arrow_reads_its_parameters() { + assert_emits("{{ run((o) => !o) }}", "i0.ɵɵtextInterpolate(ctx.run((o)=>!o));"); + assert_emits("{{ run((a, b) => a + b) }}", "i0.ɵɵtextInterpolate(ctx.run((a,b)=>(a+b)));"); + assert_emits( + "{{ run((o) => o || fallback()) }}", + "i0.ɵɵtextInterpolate(ctx.run((o)=>(o||ctx.fallback())));", + ); + assert_emits("{{ run(() => !open()) }}", "i0.ɵɵtextInterpolate(ctx.run(()=>!ctx.open()));"); +} + +#[test] +fn let_declaration_arrow_reads_its_parameter() { + assert_emits("@let fn = (a) => a + other; {{ fn(1) }}", "constfn_r1=(a)=>(a+ctx.other);"); +} + +#[test] +fn host_listener_arrow_reads_its_parameter() { + let allocator = Allocator::default(); + let options = + TransformOptions { compilation_mode: CompilationMode::Full, ..Default::default() }; + let source = "import {Directive, signal} from '@angular/core'; +@Directive({ + selector: '[d]', + host: { + '(click)': 'someSignal.update(prev => prev + 1)', + '(mousedown)': 'someSignal.update(() => componentProp + 1)', + }, +}) +export class D { + someSignal = signal(0); + componentProp = 1; +} +"; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + assert!(!result.has_errors(), "should not have errors, got: {:?}", result.diagnostics); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(code.contains("returnctx.someSignal.update((prev)=>(prev+1));"), "got:\n{code}"); + assert!( + code.contains("returnctx.someSignal.update(()=>(ctx.componentProp+1));"), + "got:\n{code}" + ); +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 7738daf01..c2c88b359 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", 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..5abc853b4 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/templates/arrow-functions.fixture.ts @@ -0,0 +1,169 @@ +/** + * 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. + * + * Only event bindings are covered: there the arrow stays in place. Elsewhere + * Angular hoists it through ɵɵarrowFunction, which is not implemented yet. + * + * 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'], + }, +] From c63b9a61a9266f85119a39ee2fe8f8c251472f7f Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 9 Oct 2026 20:03:15 +0100 Subject: [PATCH 4/8] fix(hmr): write the new template's fields and stop redefining the live definition (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hmr): write the new template's fields and stop redefining the live definition The update module `compileForHmrSync` returns replaced a component's template like this: X.ɵcmp = i0.ɵɵdefineComponent({ ...X.ɵcmp, ...(X.ɵcmp.inputConfig ? { inputs: X.ɵcmp.inputConfig } : {}), template: function X_Template(rf, ctx) { ... }, }); Three things were wrong with it. - `decls`, `vars` and `ngContentSelectors` were not written, so the spread kept the old template's. A swap that added an element or a binding rebuilt a view sized for the old template and failed partway ("Index expected to be less than 30", "TNodes should be created before any bindings"). `consts` was written only when the new template had some, so a template that lost its last attribute kept the old ones. - The live definition's `outputs` went back through `ɵɵdefineComponent`, which had already converted them to `{ publicName: propertyName }` and converted them again. A component with `checked = model(false)` ended up with `outputs: { checked: 'checkedChange' }`. - The live definition has no `changeDetection`, so `onPush` was derived again as true: an Eager component became OnPush after a swap. `inputs` was already restored from `inputConfig` for the second reason. Rather than undo each conversion, the module no longer calls `ɵɵdefineComponent`. Only the template is compiled on this path, so the rest of the definition is the live one, copied as it is: X.ɵcmp = { ...X.ɵcmp, decls: 4, vars: 1, consts: null, ngContentSelectors: _c1, template: function X_Template(rf, ctx) { ... }, tView: null, }; Every field the template decides is written, including the ones the new template has no value for. `tView` is cleared so the runtime builds the view for the new template. `ɵɵreplaceMetadata` then merges this into the live definition and re-creates the views, as before. - `compile_template_for_hmr` returns `decls`, `vars` and the `ngContentSelectors` expression alongside the template. - `HmrUpdateModuleOptions` takes them as `HmrTemplateFields`. - `generate_hmr_update_module_from_js` (`generateHmrModule`), which is given only template JavaScript, emits the same shape without the counts it cannot know; its docs say so. Angular's own update module is a whole new `ɵɵdefineComponent({...})` built from the component class. That needs the class, which this template-only path does not have, so the shape differs: the definition is not rebuilt and the component id does not change. Tests: - `test/hmr-update-module.test.ts`: 18 tests that compile a component, load it with the real `@angular/core`, apply the update module through `ɵɵreplaceMetadata`, and compare the template's fields with a fresh compile of the new template and every other field with what it was. `@angular/core` is a dev dependency for this. - `e2e/tests/hmr-update-definition.spec.ts`: 6 browser tests that edit templates on disk and check what renders: elements and bindings gained and lost, `` gained, changed and lost, consts, outputs still reaching the parent after one and two swaps, the change detection strategy, and a component whose path contains `@`. * test(e2e): space repeat edits past the watcher's change throttle On Linux a hot swap is on screen about 30ms after the write, so a test that edited the same template again straight away wrote inside chokidar's 50ms change throttle and the dev server never saw the second edit. Wait 200ms before editing a file a second time. * fix(hmr): close update-module gaps found in review Follow-up to the definition-merge fix, addressing cases where a hot swap still carried stale or invalid state: - update_module: emit `tView: null` only when a template is being replaced. Styles-only updates no longer force a needless view rebuild through getOrCreateComponentTView. - update_module: `generate_hmr_update_module_from_js` emitted invalid JS (`template: ,`) for a styles-only call with empty template; empty template_js now produces a styles-only module with no template/consts/tView keys. - update_module: a template swap with no consts now emits `consts: null` instead of omitting the key, so the new template no longer indexes into the old template's constant pool. - transform: compile_template_for_hmr propagates error-severity diagnostics instead of emitting decls/vars = 0 output, and always uses DeferBlockDepsEmitMode::PerComponent (PerBlock left @defer resolver calls with no deferred-dependencies block to resolve, matching upstream handler.ts:1281 for local compilation). - lib.rs / vite-plugin: honor the component's declared encapsulation when encapsulating styles for a hot update instead of assuming Emulated, so ViewEncapsulation.None/ShadowDom components keep unshimmed CSS; error-severity diagnostics surface as errors and the plugin falls back to a full reload. - Document that the JS-facing generateHmrModule only produces correct modules when decls/vars/ngContentSelectors are unchanged; the vite plugin's live path (compileForHmrSync) writes all of them. Verified: cargo test -p oxc_angular_compiler 3065 passed, vitest hmr-update-module + ssr-hmr 30/30 against real @angular/core 22.2.1. * style: format hmr-update-module.test.ts with oxfmt * fix(hmr): resolve listener diagnostics that surfaced as full reloads The fatal-diagnostics change exposed two latent false-positive diagnostics in the template-only HMR path, which CI's e2e suite turned into page reloads for any component using `$event` or a two-way binding: - resolve_names verifier: `$event` inside listener handler ops legitimately remains a LexicalRead until reify emits it as the handler parameter (upstream's resolveDollarEvent rewrites it to a ReadVarExpr before its verifier runs; this port defers to reify). Exempt `$event` reads visited with IN_CHILD_OPERATION so plain bindings like `{{ $event }}` still error as upstream does. - transform_two_way_binding_set: validate resolved target forms (ResolvedPropertyRead/ResolvedKeyedRead/ResolvedSafePropertyRead) alongside the raw-Ast ones, matching upstream's acceptance of o.ReadPropExpr / o.ReadKeyExpr / ir.ReadVariableExpr. A resolved `[(checked)]="ioChecked"` target no longer reports "Unsupported expression in two-way action binding". Verified: cargo test 3065/3065, vitest hmr suites 30/30, and the previously failing playwright specs pass locally (hmr-css, hmr-html, hmr-html-write-strategies 12/12, hmr-update-definition + hmr-input-component 10/10). --------- Co-authored-by: LongYinan --- .../src/component/transform.rs | 51 ++- crates/oxc_angular_compiler/src/hmr/mod.rs | 19 +- .../src/hmr/update_module.rs | 387 ++++++++++++------ crates/oxc_angular_compiler/src/lib.rs | 6 +- .../src/pipeline/phases/resolve_names.rs | 30 +- .../phases/transform_two_way_binding_set.rs | 8 + napi/angular-compiler/.gitignore | 1 + .../e2e/app/src/app/@scoped/at.component.ts | 10 + .../e2e/app/src/app/@scoped/at.html | 1 + .../e2e/app/src/app/app.component.ts | 8 +- .../angular-compiler/e2e/app/src/app/app.html | 12 + .../e2e/app/src/app/io.component.ts | 22 + napi/angular-compiler/e2e/app/src/app/io.html | 1 + .../e2e/app/src/app/lab.component.ts | 18 + .../angular-compiler/e2e/app/src/app/lab.html | 1 + .../e2e/tests/hmr-input-component.spec.ts | 6 +- .../e2e/tests/hmr-update-definition.spec.ts | 265 ++++++++++++ napi/angular-compiler/index.d.ts | 13 +- napi/angular-compiler/package.json | 2 + napi/angular-compiler/src/lib.rs | 46 ++- .../test/hmr-update-module.test.ts | 346 ++++++++++++++++ napi/angular-compiler/test/ssr-hmr.test.ts | 5 +- napi/angular-compiler/vite-plugin/index.ts | 10 + pnpm-lock.yaml | 6 + 24 files changed, 1104 insertions(+), 170 deletions(-) create mode 100644 napi/angular-compiler/e2e/app/src/app/@scoped/at.component.ts create mode 100644 napi/angular-compiler/e2e/app/src/app/@scoped/at.html create mode 100644 napi/angular-compiler/e2e/app/src/app/io.component.ts create mode 100644 napi/angular-compiler/e2e/app/src/app/io.html create mode 100644 napi/angular-compiler/e2e/app/src/app/lab.component.ts create mode 100644 napi/angular-compiler/e2e/app/src/app/lab.html create mode 100644 napi/angular-compiler/e2e/tests/hmr-update-definition.spec.ts create mode 100644 napi/angular-compiler/test/hmr-update-module.test.ts diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 8637eee4a..6adfc2c41 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -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. @@ -5492,11 +5507,14 @@ pub fn compile_template_for_hmr<'a>( // OXC is a single-file compiler (local compilation mode): always use Full mode. let mode = TemplateCompilationMode::Full; - let defer_block_deps_emit_mode = if options.jit { - DeferBlockDepsEmitMode::PerComponent - } else { - DeferBlockDepsEmitMode::PerBlock - }; + // Defer-block dependency resolution needs component metadata this compile + // does not have, and PerBlock mode reports "unable to find a dependency + // function for this deferred block" for every `@defer` when its `blocks` + // map is not populated — nothing ever populates it here. Local compilation + // uses PerComponent (upstream handler.ts:1281); with no resolver available + // it still emits `ɵɵdefer` with a null dependencies argument, which is what + // PerBlock's empty map would have produced anyway. + let defer_block_deps_emit_mode = DeferBlockDepsEmitMode::PerComponent; let enable_debug_locations = !options.advanced_optimizations; @@ -5528,6 +5546,15 @@ pub fn compile_template_for_hmr<'a>( // Collect any diagnostics from the compilation job diagnostics.extend(job.diagnostics.into_iter()); + // A pipeline error means the emitted code is already wrong — an update + // module must not be generated from it, and `decls`/`vars` may not be + // reliable (they default to 0 when slot allocation never ran, which would + // size the new TView incorrectly). Non-error diagnostics go out on the + // output so the caller can surface them. + if diagnostics.iter().any(|d| d.severity == oxc_diagnostics::Severity::Error) { + return Err(diagnostics); + } + let emitter = JsEmitter::new(); // Emit the template function @@ -5606,7 +5633,19 @@ pub fn compile_template_for_hmr<'a>( None }; - Ok(HmrTemplateCompileOutput { template_js, declarations_js, styles, consts_js }) + let ng_content_selectors_js = + job.content_selectors.take().map(|selectors| emitter.emit_expression(&selectors)); + + Ok(HmrTemplateCompileOutput { + template_js, + declarations_js, + styles, + consts_js, + decls: job.root.decl_count.unwrap_or(0), + vars: job.root.vars.unwrap_or(0), + ng_content_selectors_js, + diagnostics, + }) } /// Generate component compilation output for HMR. diff --git a/crates/oxc_angular_compiler/src/hmr/mod.rs b/crates/oxc_angular_compiler/src/hmr/mod.rs index c9629de93..85d709e5b 100644 --- a/crates/oxc_angular_compiler/src/hmr/mod.rs +++ b/crates/oxc_angular_compiler/src/hmr/mod.rs @@ -27,10 +27,18 @@ //! }); //! //! // HMR Update Module (served by Vite middleware) -//! export default { -//! ɵcmp: /* compiled component definition */, -//! template: /* template function */, -//! }; +//! export default function Cmp_UpdateMetadata(Cmp, ɵɵnamespaces) { +//! const i0 = ɵɵnamespaces[0]; +//! Cmp.ɵcmp = { +//! ...Cmp.ɵcmp, +//! decls: /* new template's slot count */, +//! vars: /* new template's binding count */, +//! consts: /* new template's consts, or null */, +//! ngContentSelectors: /* new template's selectors, or undefined */, +//! template: /* new template function */, +//! tView: null, +//! }; +//! } //! ``` mod dependencies; @@ -46,5 +54,6 @@ pub use dependencies::{ pub use initializer::{HmrDefinition, compile_hmr_initializer, compile_hmr_update_callback}; pub use styles::generate_style_update_module; pub use update_module::{ - HmrUpdateModuleOptions, generate_hmr_update_module, generate_hmr_update_module_from_js, + HmrTemplateFields, HmrUpdateModuleOptions, generate_hmr_update_module, + generate_hmr_update_module_from_js, }; diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index 77a230ddd..d19d9098c 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -6,6 +6,28 @@ //! The generated modules export a function that Angular's `ɵɵreplaceMetadata` //! can call to apply the updated component definition. +/// The fields of a component definition that its template decides, from a compile of +/// the new template. +#[derive(Debug, Clone, Copy)] +pub struct HmrTemplateFields<'a> { + /// The compiled template function as JavaScript code. + pub template_js: &'a str, + + /// 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 consts array (or the function returning it) as JavaScript code, if the + /// template has any. + pub consts_js: Option<&'a str>, + + /// The `ngContentSelectors` array as JavaScript code, if the template projects + /// content. + pub ng_content_selectors_js: Option<&'a str>, +} + /// Options for generating HMR update modules. #[derive(Debug, Clone)] pub struct HmrUpdateModuleOptions<'a> { @@ -15,8 +37,8 @@ pub struct HmrUpdateModuleOptions<'a> { /// Component class name. pub class_name: &'a str, - /// Compiled template as JavaScript code. - pub template_js: Option<&'a str>, + /// The new template's fields, or `None` when only the styles changed. + pub template: Option>, /// Compiled styles (CSS strings). pub styles: Option<&'a [String]>, @@ -24,18 +46,14 @@ pub struct HmrUpdateModuleOptions<'a> { /// Constant declarations (child view functions, pooled constants) as JavaScript code. /// These are emitted before the component definition update. pub declarations_js: Option<&'a str>, - - /// The consts array as JavaScript code. - /// This must be included to ensure the template function's constant references - /// match the component definition's consts array. - pub consts_js: Option<&'a str>, } /// Generate an HMR update module for a component. /// /// This generates a JavaScript module that exports a function which updates /// the component's definition. The function is called by Angular's -/// `ɵɵreplaceMetadata` runtime function. +/// `ɵɵreplaceMetadata` runtime function, which merges the definition it leaves +/// on the class into the live one and re-creates the component's views. /// /// # Generated Format /// @@ -43,14 +61,31 @@ pub struct HmrUpdateModuleOptions<'a> { /// // HMR update for: path/to/component.ts@ComponentName /// export default function ComponentName_UpdateMetadata(ComponentName, ɵɵnamespaces) { /// const i0 = ɵɵnamespaces[0]; -/// ComponentName.ɵcmp = i0.ɵɵdefineComponent({ +/// ComponentName.ɵcmp = { /// ...ComponentName.ɵcmp, +/// decls: 2, +/// vars: 1, +/// consts: null, +/// ngContentSelectors: undefined, /// template: function ComponentName_Template(rf, ctx) { ... }, /// styles: ["..."], -/// }); +/// tView: null, +/// }; /// } /// ``` /// +/// Angular's own update module (`compileHmrUpdateCallback`) writes a whole new +/// `ɵɵdefineComponent({...})` from the component class. Only the template is +/// compiled here, so the rest of the definition is the live one, copied as it +/// is. It must not go back through `ɵɵdefineComponent`: that function converts +/// `inputs` and `outputs` and derives `onPush` from `changeDetection`, so a +/// definition it has already built comes out with its outputs reversed and its +/// change detection strategy lost. +/// +/// Every field the template decides is written, including the ones the new +/// template has no value for, because a key left out keeps the old template's. +/// `tView` is cleared so the runtime builds the view for the new template. +/// /// # Arguments /// /// * `options` - HMR update module options @@ -59,27 +94,52 @@ pub struct HmrUpdateModuleOptions<'a> { /// /// JavaScript code for the HMR update module. pub fn generate_hmr_update_module(options: &HmrUpdateModuleOptions<'_>) -> String { - generate_hmr_update_module_internal( + let mut fields = std::vec::Vec::new(); + if let Some(template) = &options.template { + fields.push(("decls", template.decls.to_string())); + fields.push(("vars", template.vars.to_string())); + fields.push(("consts", template.consts_js.unwrap_or("null").to_string())); + fields.push(( + "ngContentSelectors", + template.ng_content_selectors_js.unwrap_or("undefined").to_string(), + )); + fields.push(("template", template.template_js.to_string())); + } + emit_update_module( options.component_id, options.class_name, - options.template_js, + &fields, options.styles, options.declarations_js, - options.consts_js, + // A missing template is a styles-only update: the live tView still + // matches the template it was built for, so keep it and let + // recreateMatchingLViews re-render rather than rebuilding it. + options.template.is_some(), ) } /// Generate an HMR update module from a compiled template string. /// /// This is a convenience function when you already have the template -/// compiled to JavaScript. +/// compiled to JavaScript. It knows only what it is given: the module replaces +/// the template function and `consts`, but `decls`, `vars` and +/// `ngContentSelectors` stay as the live definition has them — keys the module +/// does not write survive the `...ɵcmp` spread — so it is only correct for a +/// template that needs the same slot counts and projection selectors as the +/// old one. For a template that changes them, use +/// [`generate_hmr_update_module`] with the output of a template compile, which +/// writes them all. /// /// # Arguments /// /// * `component_id` - Component ID (path@ClassName) -/// * `template_js` - Compiled template function as JavaScript +/// * `template_js` - Compiled template function as JavaScript, or an empty +/// string for a styles-only update /// * `styles` - Optional array of CSS styles /// * `declarations_js` - Optional constant declarations (child views, pooled constants) +/// * `consts_js` - The new template's consts array as JavaScript. When the +/// module emits a template, `None` writes `consts: null`, clearing whatever +/// the old template's consts held. /// /// # Returns /// @@ -95,36 +155,45 @@ pub fn generate_hmr_update_module_from_js( // can contain `@` (e.g. `node_modules/@scope/...`) but a class name cannot. let class_name = component_id.rsplit_once('@').map_or("Component", |(_, name)| name); - generate_hmr_update_module_internal( + let mut fields = std::vec::Vec::new(); + if !template_js.is_empty() { + fields.push(("template", template_js.to_string())); + // A swapped template decides the consts it indexes into, so an absent + // `consts_js` clears them rather than keeping the old template's. + fields.push(("consts", consts_js.unwrap_or("null").to_string())); + } + emit_update_module( component_id, class_name, - Some(template_js), + &fields, styles, declarations_js, - consts_js, + !template_js.is_empty(), ) } -/// Internal function to generate HMR update module. -fn generate_hmr_update_module_internal( +/// Emits an update module that copies the live definition and overrides `fields` +/// (name and JavaScript value) and, when known, `styles`. `clear_tview` writes +/// `tView: null`, making the runtime build the view for the new template; it +/// must only be set when `fields` replaces the template. +fn emit_update_module( component_id: &str, class_name: &str, - template_js: Option<&str>, + fields: &[(&str, String)], styles: Option<&[String]>, declarations_js: Option<&str>, - consts_js: Option<&str>, + clear_tview: bool, ) -> String { let mut output = String::new(); // Add comment with component ID - output.push_str(&format!("// HMR update for: {}\n", component_id)); + output.push_str(&format!("// HMR update for: {component_id}\n")); // Export a function that Angular's ɵɵreplaceMetadata will call // The function signature matches what compileHmrUpdateCallback generates: // function ClassName_UpdateMetadata(ClassName, ɵɵnamespaces, ...locals) output.push_str(&format!( - "export default function {}_UpdateMetadata({}, ɵɵnamespaces) {{\n", - class_name, class_name + "export default function {class_name}_UpdateMetadata({class_name}, ɵɵnamespaces) {{\n" )); // Destructure the Angular core namespace from namespaces array @@ -133,46 +202,19 @@ fn generate_hmr_update_module_internal( // Add constant declarations (child view functions, pooled constants) // These must be declared before the component definition since the template may reference them if let Some(declarations) = declarations_js { - if !declarations.is_empty() { - // Indent each line of declarations for consistent formatting - for line in declarations.lines() { - output.push_str(" "); - output.push_str(line); - output.push('\n'); - } + // Indent each line of declarations for consistent formatting + for line in declarations.lines() { + output.push_str(" "); + output.push_str(line); + output.push('\n'); } } - // Update the component definition using ɵɵdefineComponent - // We spread the existing definition and override template/styles. - // IMPORTANT: We must override `inputs` with `inputConfig` because the spread - // includes `inputs` in the already-processed format (output of - // `parseAndConvertInputsForDefinition`). If we don't override, ɵɵdefineComponent - // will process them again, producing corrupted input mappings. - // `inputConfig` stores the original unprocessed inputs format. - // Only override when inputConfig exists (components with inputs); otherwise - // setting `inputs: undefined` would corrupt the component definition. - output.push_str(&format!(" {}.ɵcmp = i0.ɵɵdefineComponent({{\n", class_name)); - output.push_str(&format!(" ...{}.ɵcmp,\n", class_name)); - output.push_str(&format!( - " ...({cn}.ɵcmp.inputConfig ? {{ inputs: {cn}.ɵcmp.inputConfig }} : {{}}),\n", - cn = class_name - )); + output.push_str(&format!(" {class_name}.ɵcmp = {{\n")); + output.push_str(&format!(" ...{class_name}.ɵcmp,\n")); - // Add template function if present - if let Some(template_js) = template_js { - output.push_str(" template: "); - output.push_str(template_js); - output.push_str(",\n"); - } - - // Add consts array if present - // This must be included to override the old consts from the spread, ensuring - // the template function's constant references match the actual consts array - if let Some(consts_js) = consts_js { - output.push_str(" consts: "); - output.push_str(consts_js); - output.push_str(",\n"); + for (name, value) in fields { + output.push_str(&format!(" {name}: {value},\n")); } // Add styles whenever the caller has an answer — an EMPTY list included. @@ -195,7 +237,10 @@ fn generate_hmr_update_module_internal( } } - output.push_str(" });\n"); + if clear_tview { + output.push_str(" tView: null,\n"); + } + output.push_str(" };\n"); output.push_str("}\n"); output @@ -205,34 +250,112 @@ fn generate_hmr_update_module_internal( mod tests { use super::*; + const TEMPLATE: HmrTemplateFields<'static> = HmrTemplateFields { + template_js: "function AppComponent_Template(rf, ctx) { }", + decls: 3, + vars: 2, + consts_js: None, + ng_content_selectors_js: None, + }; + + fn module( + component_id: &str, + template: Option>, + styles: Option<&[String]>, + declarations_js: Option<&str>, + ) -> String { + // The path can contain `@` (e.g. `node_modules/@scope/...`); a class name cannot. + let class_name = component_id.rsplit_once('@').map_or("Component", |(_, name)| name); + generate_hmr_update_module(&HmrUpdateModuleOptions { + component_id, + class_name, + template, + styles, + declarations_js, + }) + } + #[test] - fn test_generate_hmr_update_module_from_js() { - let result = generate_hmr_update_module_from_js( + fn test_generate_hmr_update_module() { + let result = module( "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { }", + Some(TEMPLATE), Some(&["h1 { color: red; }".to_string()]), None, - None, ); assert!(result.contains("export default function AppComponent_UpdateMetadata")); assert!(result.contains("const i0 = ɵɵnamespaces[0]")); - assert!(result.contains("AppComponent.ɵcmp = i0.ɵɵdefineComponent")); - assert!(result.contains("template:")); + assert!(result.contains("template: function AppComponent_Template(rf, ctx) { },")); assert!(result.contains("styles:")); assert!(result.contains("color: red")); } + /// The live definition is copied, never handed back to `ɵɵdefineComponent`, which + /// would convert its `inputs` and `outputs` a second time. #[test] - fn test_generate_hmr_update_module_no_styles() { - let result = generate_hmr_update_module_from_js( + fn test_live_definition_is_copied_not_redefined() { + let result = module("src/app/app.component.ts@AppComponent", Some(TEMPLATE), None, None); + + assert!(result.contains("AppComponent.ɵcmp = {\n ...AppComponent.ɵcmp,\n")); + assert!(!result.contains("ɵɵdefineComponent")); + assert!(!result.contains("inputConfig")); + // The runtime builds the view for the new template only if none is cached. + assert!(result.contains(" tView: null,\n };")); + } + + /// Every field the template decides is written after the spread, so none keeps the + /// old template's value: not even one the new template has nothing for. + #[test] + fn test_template_fields_are_all_written() { + let result = module("src/app/app.component.ts@AppComponent", Some(TEMPLATE), None, None); + + let spread = result.find("...AppComponent.ɵcmp").unwrap(); + for field in ["decls: 3,", "vars: 2,", "consts: null,", "ngContentSelectors: undefined,"] { + let at = result.find(field).unwrap_or_else(|| panic!("missing `{field}`:\n{result}")); + assert!(at > spread, "`{field}` must override the spread"); + } + } + + #[test] + fn test_consts_and_content_selectors() { + let result = module( "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { }", + Some(HmrTemplateFields { + consts_js: Some("[\"value1\", 42, [1, 2, 3]]"), + ng_content_selectors_js: Some("_c1"), + ..TEMPLATE + }), None, + Some("const _c0 = [[[\"\", \"x\", \"\"]]];\nconst _c1 = [\"[x]\"];"), + ); + + assert!(result.contains("consts: [\"value1\", 42, [1, 2, 3]],")); + assert!(result.contains("ngContentSelectors: _c1,")); + } + + /// A styles-only update leaves the template's fields, and the view built for it, alone. + #[test] + fn test_styles_only_update() { + let result = module( + "src/app/app.component.ts@AppComponent", None, + Some(&["h1 { color: red; }".to_string()]), None, ); + // `tView` included: clearing it would make getOrCreateComponentTView + // rebuild an identical view before recreateMatchingLViews re-renders. + for field in ["decls:", "vars:", "consts:", "ngContentSelectors:", "template:", "tView:"] { + assert!(!result.contains(field), "unexpected `{field}`:\n{result}"); + } + assert!(result.contains("color: red")); + } + + #[test] + fn test_generate_hmr_update_module_no_styles() { + let result = module("src/app/app.component.ts@AppComponent", Some(TEMPLATE), None, None); + assert!(result.contains("export default function AppComponent_UpdateMetadata")); assert!(result.contains("template:")); assert!(!result.contains("styles:")); @@ -244,100 +367,94 @@ mod tests { // with `...ClassName.ɵcmp`, so a key it does not emit keeps its old // value — omitting `styles` here would leave the component's last // stylesheet applied until a full reload. - let result = generate_hmr_update_module_from_js( - "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { }", - Some(&[]), - None, - None, - ); + let result = + module("src/app/app.component.ts@AppComponent", Some(TEMPLATE), Some(&[]), None); assert!(result.contains("styles: [],")); } #[test] - fn test_class_name_extraction() { - let result = generate_hmr_update_module_from_js( + fn test_class_name_in_function_and_definition() { + for id in [ "path/to/my.component.ts@MyComponent", - "function MyComponent_Template(rf, ctx) { }", - None, - None, - None, - ); - - assert!(result.contains("function MyComponent_UpdateMetadata(MyComponent")); - } - - #[test] - fn test_class_name_extraction_path_with_at() { - for id in - ["node_modules/@scope/pkg/src/a.ts@MyComponent", "packages/@a/@b/x.ts@MyComponent"] - { - let result = generate_hmr_update_module_from_js(id, "", None, None, None); + "node_modules/@scope/pkg/src/a.ts@MyComponent", + "packages/@a/@b/x.ts@MyComponent", + ] { + let result = module(id, Some(TEMPLATE), None, None); assert!(result.contains("function MyComponent_UpdateMetadata(MyComponent"), "{id}"); assert!(result.contains("MyComponent.ɵcmp ="), "{id}"); + assert!(result.contains(&format!("// HMR update for: {id}\n")), "{id}"); } } + /// From template JavaScript alone the slot counts are unknown, so they are + /// left as they are — `decls`, `vars` and `ngContentSelectors` keep the old + /// template's values, which is only correct when they do not change. #[test] - fn test_class_name_extraction_no_at_falls_back() { - let result = generate_hmr_update_module_from_js("MyComponent", "", None, None, None); - assert!(result.contains("function Component_UpdateMetadata(Component")); - } - - #[test] - fn test_generate_hmr_update_module_with_declarations() { - let declarations = "function App_For_1(rf, ctx) { }\nconst _c0 = [1, 2, 3];"; + fn test_generate_hmr_update_module_from_js() { let result = generate_hmr_update_module_from_js( - "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { App_For_1(rf, ctx); }", - None, - Some(declarations), + "node_modules/@scope/pkg/src/a.ts@MyComponent", + "function MyComponent_Template(rf, ctx) { }", + Some(&["h1 { color: red; }".to_string()]), None, + Some("[\"value1\"]"), ); - assert!(result.contains("export default function AppComponent_UpdateMetadata")); - assert!(result.contains("function App_For_1(rf, ctx)")); - assert!(result.contains("const _c0 = [1, 2, 3]")); - // Declarations should come before component definition - let decl_pos = result.find("App_For_1").unwrap(); - let cmp_pos = result.find("ɵcmp").unwrap(); - assert!(decl_pos < cmp_pos); - } + assert!(result.contains("function MyComponent_UpdateMetadata(MyComponent")); + assert!(result.contains("MyComponent.ɵcmp = {\n ...MyComponent.ɵcmp,\n")); + assert!(result.contains("template: function MyComponent_Template(rf, ctx) { },")); + assert!(result.contains("consts: [\"value1\"],")); + assert!(result.contains("color: red")); + assert!(result.contains(" tView: null,\n };")); + assert!(!result.contains("ɵɵdefineComponent")); + assert!(!result.contains("decls:")); + assert!(!result.contains("ngContentSelectors:")); - #[test] - fn test_generate_hmr_update_module_uses_input_config() { + // The swapped template decides `consts`: no consts argument clears the + // old template's rather than keeping it through the spread. let result = generate_hmr_update_module_from_js( - "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { }", + "a.ts@MyComponent", + "function T(rf, ctx) {}", None, None, None, ); + assert!(result.contains("consts: null,"), "{result}"); - // The HMR module must conditionally override `inputs` with `inputConfig` - // to avoid double-processing by `parseAndConvertInputsForDefinition`. - // It must only do so when inputConfig exists to avoid setting inputs to undefined. - assert!(result.contains("AppComponent.ɵcmp.inputConfig")); - assert!(result.contains("inputs:")); - - // `inputs` override must come AFTER the spread to take precedence - let spread_pos = result.find("...AppComponent.ɵcmp").unwrap(); - let inputs_pos = result.find("inputConfig").unwrap(); - assert!(inputs_pos > spread_pos); + // No template is a styles-only update: no template key (which would be + // `template: ,` — invalid JS) and no `tView` clear. + let result = generate_hmr_update_module_from_js( + "MyComponent", + "", + Some(&["h1 { color: red; }".to_string()]), + None, + None, + ); + assert!(result.contains("function Component_UpdateMetadata(Component")); + assert!(!result.contains("template:"), "{result}"); + assert!(!result.contains("tView"), "{result}"); + assert!(result.contains("color: red")); } #[test] - fn test_generate_hmr_update_module_with_consts() { - let result = generate_hmr_update_module_from_js( + fn test_generate_hmr_update_module_with_declarations() { + let declarations = "function App_For_1(rf, ctx) { }\nconst _c0 = [1, 2, 3];"; + let result = module( "src/app/app.component.ts@AppComponent", - "function AppComponent_Template(rf, ctx) { }", - None, + Some(HmrTemplateFields { + template_js: "function AppComponent_Template(rf, ctx) { App_For_1(rf, ctx); }", + ..TEMPLATE + }), None, - Some("[\"value1\", 42, [1, 2, 3]]"), + Some(declarations), ); assert!(result.contains("export default function AppComponent_UpdateMetadata")); - assert!(result.contains("consts: [\"value1\", 42, [1, 2, 3]]")); + assert!(result.contains("function App_For_1(rf, ctx)")); + assert!(result.contains("const _c0 = [1, 2, 3]")); + // Declarations should come before component definition + let decl_pos = result.find("App_For_1").unwrap(); + let cmp_pos = result.find("ɵcmp").unwrap(); + assert!(decl_pos < cmp_pos); } } diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 50a3029ce..b985030ea 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -78,9 +78,9 @@ pub use component::CrossFileAnalyzer; // Re-export HMR types pub use hmr::{ HmrDefinition, HmrDependencies, HmrLocalDependency, HmrMetadata, HmrNamespaceDependency, - HmrUpdateModuleOptions, compile_hmr_initializer, compile_hmr_update_callback, - extract_hmr_dependencies, generate_hmr_update_module, generate_hmr_update_module_from_js, - generate_style_update_module, + HmrTemplateFields, HmrUpdateModuleOptions, compile_hmr_initializer, + compile_hmr_update_callback, extract_hmr_dependencies, generate_hmr_update_module, + generate_hmr_update_module_from_js, generate_style_update_module, }; // Re-export styles diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs index a4a95a280..3c750a149 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs @@ -17,10 +17,11 @@ use rustc_hash::FxHashMap; use crate::ast::expression::AngularExpression; use crate::ir::enums::SemanticVariableKind; use crate::ir::expression::{ - ContextExpr, IrExpression, ReadVariableExpr, ResolvedCallExpr, ResolvedKeyedReadExpr, - ResolvedPropertyReadExpr, ResolvedSafePropertyReadExpr, RestoreViewTarget, VisitorContextFlag, - transform_expressions_in_create_op, transform_expressions_in_update_op, - visit_expressions_in_create_op, visit_expressions_in_update_op, + ContextExpr, IrExpression, LexicalReadExpr, ReadVariableExpr, ResolvedCallExpr, + ResolvedKeyedReadExpr, ResolvedPropertyReadExpr, ResolvedSafePropertyReadExpr, + RestoreViewTarget, VisitorContextFlag, transform_expressions_in_create_op, + transform_expressions_in_update_op, visit_expressions_in_create_op, + visit_expressions_in_update_op, }; use crate::ir::ops::{CreateOp, UpdateOp, XrefId}; use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob}; @@ -1827,13 +1828,27 @@ fn verify_no_lexical_reads_remain(job: &mut ComponentCompilationJob<'_>) { // Collect errors in a RefCell to allow mutation from within the Fn closure let errors: RefCell> = RefCell::new(Vec::new()); + // `$event` inside listener handlers (IN_CHILD_OPERATION) legitimately stays + // a LexicalRead until reify turns it into the handler parameter. Upstream's + // resolveDollarEvent rewrites it to a ReadVarExpr before this check, so its + // verifier never sees it; this port leaves the LexicalRead in place instead, + // so it is exempt here. + let is_exempt_event_param = |lexical: &LexicalReadExpr<'_>, + flags: VisitorContextFlag| + -> bool { + flags.contains(VisitorContextFlag::IN_CHILD_OPERATION) && lexical.name.as_str() == "$event" + }; + for view in job.all_views() { // Check create ops for op in view.create.iter() { visit_expressions_in_create_op( op, - &|expr, _flags| { + &|expr, flags| { if let IrExpression::LexicalRead(lexical) = expr { + if is_exempt_event_param(lexical, flags) { + return; + } errors.borrow_mut().push( OxcDiagnostic::error(format!( "AssertionError: no lexical reads should remain, but found read of {}", @@ -1851,8 +1866,11 @@ fn verify_no_lexical_reads_remain(job: &mut ComponentCompilationJob<'_>) { for op in view.update.iter() { visit_expressions_in_update_op( op, - &|expr, _flags| { + &|expr, flags| { if let IrExpression::LexicalRead(lexical) = expr { + if is_exempt_event_param(lexical, flags) { + return; + } errors.borrow_mut().push( OxcDiagnostic::error(format!( "AssertionError: no lexical reads should remain, but found read of {}", diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/transform_two_way_binding_set.rs b/crates/oxc_angular_compiler/src/pipeline/phases/transform_two_way_binding_set.rs index 73e495d75..5fe690a66 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/transform_two_way_binding_set.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/transform_two_way_binding_set.rs @@ -114,6 +114,14 @@ fn validate_two_way_binding_target(expr: &IrExpression<'_>) -> Option None, // Other IR expression types are not valid for two-way binding _ => Some( OxcDiagnostic::error("Unsupported expression in two-way action binding") 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/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..7a37a173a 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -498,12 +498,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..a7779bfa6 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(), 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/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 10972e2b4..d2787a257 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -828,9 +828,19 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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 From 85fb775cf86472878c5ad73963c218239a789857 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Sat, 10 Oct 2026 07:17:44 +0100 Subject: [PATCH 5/8] =?UTF-8?q?feat(compiler):=20hoist=20template=20arrow?= =?UTF-8?q?=20functions=20through=20=C9=B5=C9=B5arrowFunction=20(#567)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compiler): resolve arrow function parameters in template expressions An arrow function in a template that read its own parameter was compiled as though the parameter were a component property: `open.update((o) => !o)` emitted `(o) => !ctx.o`, which reads `undefined` at runtime with no diagnostic. Ingest had no arm for `ArrowFunction`, so the whole arrow was stored as a raw AST blob that skipped name resolution, and reify then turned every implicit-receiver read in it into `ctx.`. That also meant template variables and, in embedded views, the component itself were read off the wrong `ctx` inside an arrow body. - ingest.rs: convert arrow functions to IR and port Angular's `updateParameterReferences`, which replaces reads of an arrow's parameters (and of enclosing arrows' parameters) with plain variable reads before name resolution runs. Covers templates and host bindings. - resolve_names.rs: resolve the remaining names in an arrow body against template variables and the component context. - reify/ir_expression.rs: emit the arrow in place. - var_counting.rs: an in-place arrow does not reserve a var slot. Verified against ngtsc 22.2.1. Arrows in event bindings now match it, including the four listener cases ported from Angular's `r3_view_compiler_arrow_functions` compliance suite. Outside listeners ngtsc hoists the arrow through `ɵɵarrowFunction`, which is not implemented yet: the arrow is still emitted in place, now with the same body as ngtsc's hoisted function. * fix(directive): declare constants pooled by host bindings A `@Directive` host binding that needs a pooled constant referenced it without declaring it. `host: {'[attr.x]': 'f([a, b])'}` emitted `i0.ɵɵpureFunction2(1, _c0, ctx.a, ctx.b)` with no `const _c0`, which throws a ReferenceError the first time the binding runs. Components already declared theirs. `compile_directive` returned the declarations, but `generate_dir_definition` dropped them. They are now carried on `DirectiveDefinitions` and emitted before the class. * feat(compiler): hoist template arrow functions through ɵɵarrowFunction Outside event listeners, ngtsc does not emit a template arrow function in place. It hoists it to a shared factory and instantiates it once per view: const arrowFn0 = (ctx, view) => (o) => !o; i0.ɵɵproperty("title", ctx.run(i0.ɵɵarrowFunction(1, arrowFn0, ctx))); We emitted the arrow inline, so a new function was created on every change detection pass. This ports the hoisting. - ir: `ArrowFunctionExpr` gains a `hoisted` flag, set by generateArrowFunctions for every arrow that is not inside a listener or nested in another arrow. Its ops now survive cloning. - A hoisted arrow is its own lexical scope. generateVariables, saveAndRestoreView, resolveNames, resolveContexts, generateTemporaryVariables and optimizeVariables process its ops and body the way they process a listener's handler, through a new `ArrowFunctionExpr::with_handler`. In an embedded view the arrow restores the view it was created in and resets it around its result. - naming: variables that come from a parent view share their name with the rest of the view; only the arrow's own view scope is fresh. Let reference variables record their declaring view so this can be told. - countVariables reserves a var slot per hoisted arrow, and reify emits the factory into the constant pool as `arrowFn`, sharing equivalent factories. Those names take part in the per-file offset so two compilations in one file cannot collide. - output: cloning a `WrappedIrNode` cloned it as `undefined`; it is now cloned properly, since a hoisted arrow keeps such statements. The phases keep pointers to the hoisted arrows of a view, and several phases rebuild expressions, so the list is collected again by each phase that walks it. Verified against ngtsc 22.2.1 with 55 compare fixtures, including every case in Angular's `r3_view_compiler_arrow_functions` compliance suite that does not need `legacyOptionalChaining`. Two deliberate differences from ngtsc: - An arrow in a `@for` `track` expression stays in place. ngtsc hoists it into the track function, where `ctx` is not in scope, so its output throws a ReferenceError. - When one file has several compilations (a class with both host bindings and a template, or several classes), ngtsc numbers `arrowFn` constants file-wide and shares identical ones between them. Constant pools here are per compilation, so the names are unique but not contiguous and identical arrows are not shared across pools. * fix(compiler): resolve names in spread arguments and tagged templates A spread call argument and a tagged template literal were stored as raw expressions that skipped name resolution, so the names they read were taken off the current view's `ctx` whatever they referred to. Inside a `@for`: {{ f(1, ...item) }} -> ctx_r0.f(1, ctx.item) {{ tag`a${item}` }} -> ctx.tag(