From 09f95f3a14ba914bd29551fc6a1efaff3d4cbe85 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:50:29 +0800 Subject: [PATCH 01/22] fix(host): report empty `[]` / `()` host keys like ngtsc (#704) ngtsc's verifyHostBindings reports "Event name is missing in binding" for a `()` key and "Property name is missing in binding" for a `[]` key, then the event-binding errors, and joins all messages with "\n" into one NG5001. Oxc accepted the empty keys and stopped at the first `[on*]` key. Collect the errors in ngtsc's order and join them. `[ ]` / `( )`, the HostBinding / HostListener decorators and the linker stay as they were, which matches ngtsc and Angular's linker. Co-Authored-By: Claude Opus 5.5 --- .../src/directive/decorator.rs | 29 ++++++-- .../tests/host_metadata_evaluation_test.rs | 68 +++++++++++++++++++ .../oxc_angular_compiler/tests/linker_test.rs | 19 ++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 7efed583a..bfaf258c8 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -2115,10 +2115,26 @@ fn evaluate_host_metadata_uncached<'a>( if let Some(error) = parse_error { return Err(error); } - // `verifyHostBindings`' `validateNoEventBindings`: binding a host property - // or attribute to an event (`[onclick]`, `[attr.onload]`) is disallowed - // (compiler.ts). The other errors it collects come from parsing the - // binding expressions, which oxc doesn't do. + // `verifyHostBindings` (compiler.ts) collects every error, in this order, + // and ngtsc joins their messages with "\n" into one NG5001: + // 1. `createDirectiveHostEventAsts`: `()` has no event name. + // 2. `createBoundHostProperties`: `[]` has no property name. + // 3. `validateNoEventBindings`: binding a host property or attribute to an + // event (`[onclick]`, `[attr.onload]`) is disallowed. + // The other errors these steps report come from parsing the binding + // expressions, which oxc doesn't do. Only the name inside the brackets is + // checked, so `[ ]` and `( )` are not errors. + let mut errors: std::vec::Vec = std::vec::Vec::new(); + for (key, _) in &entries { + if matches!(HostKey::of(key.as_str()), HostKey::Listener) && key.len() == 2 { + errors.push("Event name is missing in binding".to_string()); + } + } + for (key, _) in &entries { + if matches!(HostKey::of(key.as_str()), HostKey::Property) && key.len() == 2 { + errors.push("Property name is missing in binding".to_string()); + } + } for (key, _) in &entries { let HostKey::Property = HostKey::of(key.as_str()) else { continue }; let prop = &key.as_str()[1..key.as_str().len() - 1]; @@ -2137,9 +2153,12 @@ fn evaluate_host_metadata_uncached<'a>( by the current module." )); } - return Err(msg); + errors.push(msg); } } + if !errors.is_empty() { + return Err(errors.join("\n")); + } Ok(entries) } diff --git a/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs index 2322ddbc6..4c0b06934 100644 --- a/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs +++ b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs @@ -700,6 +700,74 @@ fn binding_to_an_event_is_disallowed() { assert_same_as_literal("Component", "const H = { '(onx)': 'go()', onx: 'a' };", "H", literal); } +/// #704: `verifyHostBindings` reports every bad key, and ngtsc 22.2.4 joins +/// the messages with "\n" in one NG5001. +#[test] +fn every_event_binding_is_reported() { + assert_error( + "", + "{ '[onclick]': 't', '[attr.onload]': 't' }", + "Binding to event property 'onclick' is disallowed for security reasons, please use \ + (click)=...\nIf 'onclick' is a directive input, make sure the directive is imported by \ + the current module.\nBinding to event attribute 'onload' is disallowed for security \ + reasons, please use (load)=...", + ); +} + +/// #704: a `()` or `[]` key has no name. ngtsc 22.2.4 reports the listeners +/// first, then the properties, then the event bindings, whatever the order +/// of the keys. +#[test] +fn empty_binding_name_is_an_error() { + const EVENT: &str = "Event name is missing in binding"; + const PROPERTY: &str = "Property name is missing in binding"; + const ONCLICK: &str = "Binding to event property 'onclick' is disallowed for security \ + reasons, please use (click)=...\nIf 'onclick' is a directive input, \ + make sure the directive is imported by the current module."; + assert_error("", "{ '()': 'go()' }", EVENT); + assert_error("", "{ '[]': 't' }", PROPERTY); + assert_error("", "{ '[]': 't', '()': 'go()' }", &format!("{EVENT}\n{PROPERTY}")); + assert_error("", "{ '[]': 't', '[onclick]': 't' }", &format!("{PROPERTY}\n{ONCLICK}")); + assert_error("", "{ '[onclick]': 't', '[]': 't' }", &format!("{PROPERTY}\n{ONCLICK}")); + // An evaluated host: a constant, a spread, a key given twice. + assert_error("const H = { '[]': 't' };", "H", PROPERTY); + assert_error( + "const S = { '()': 'go()' };", + "{ ...S, '[]': 't' }", + &format!("{EVENT}\n{PROPERTY}"), + ); + assert_error( + "const S = { '[]': 't' };", + "{ '[]': 'w', ...S, '()': 'go()' }", + &format!("{EVENT}\n{PROPERTY}"), + ); +} + +/// Only the name inside the brackets is checked, so `[ ]` and `( )` compile +/// (ngtsc 22.2.4 with `typeCheckHostBindings: false`; its host type-check +/// reports NG8002 for `' '` otherwise, which oxc doesn't do). The decorators +/// are not checked by NG5001 either: `@HostListener('')` compiles, and +/// `@HostBinding('')` only fails ngtsc's type-check. +#[test] +fn blank_or_decorator_binding_name_is_not_an_error() { + let source = source("Directive", "", "{ '[ ]': 't', '( )': 'go()' }"); + let (code, diagnostics) = compile(&source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert!(code.contains(r#"i0.ɵɵlistener(" ",function X___HostBindingHandler()"#), "{code}"); + assert!(code.contains(r#"i0.ɵɵdomProperty(" ",ctx.t)"#), "{code}"); + + let code = host_part( + "import { Directive, HostBinding, HostListener } from '@angular/core'; +@Directive({ selector: '[x]' }) +export class X { @HostBinding('') a = 1; @HostListener('') b() {} }", + ); + assert!( + code.contains(r#"i0.ɵɵlistener("",functionX__HostBindingHandler(){returnctx.b();})"#), + "{code}" + ); + assert!(code.contains(r#"i0.ɵɵdomProperty("",ctx.a)"#), "{code}"); +} + /// A method or accessor called `host` in the decorator object is a class /// member, not metadata — ngtsc's `reflectObjectLiteral` skips it, so it must /// not produce the "must be an object" error (regression: `config_property` diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index bc242dc98..c1078137f 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1342,6 +1342,25 @@ D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.3", t assert!(linked.contains("hostVars:7"), "{linked}"); } +/// #704: Angular's linker never reads the binding parser's errors, so an +/// empty host property or listener name links (Angular 22.2.4's +/// `compileDirectiveDeclaration` gives the same output). +#[test] +fn test_link_host_empty_binding_names() { + let code = r#" +import * as i0 from "@angular/core"; +class D { +} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.2.4", type: D, isStandalone: true, selector: "[d]", host: { properties: { "": "a" }, listeners: { "": "b()" } }, ngImport: i0 }); +"#; + let linked: String = link_for_version(code, None).split_whitespace().collect(); + assert!( + linked.contains(r#"i0.ɵɵlistener("",functionD__HostBindingHandler(){returnctx.b();})"#), + "{linked}" + ); + assert!(linked.contains(r#"i0.ɵɵdomProperty("",ctx.a)"#), "{linked}"); +} + /// #689: a linked `animate.enter` host property is a create-mode callback. #[test] fn test_link_host_animate_property() { From a969bd890fe2944e585f3cd2ce5a87eaea1ccf5e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:50:29 +0800 Subject: [PATCH 02/22] fix(host): emit host `aria-*` bindings as attributes per Angular version (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angular has three eras for host `aria` property bindings: - before 20.2.0-next.2: a DOM property (kept as is) - 20.2.0-next.2 to 20.3.2, 21.0.0-next.0 to next.4: when reified, any `aria*` name longer than `aria` is written as `ɵɵattribute(ariaAttrName(name))` in the place of its DOM property - 20.3.3+, 21.0.0-next.5+: an `aria-*` property (also a legacy `[@aria-*]`, whose name has lost its `@`) becomes an attribute binding in binding specialization, so it comes after the `ɵɵdomProperty` calls Add the two version checks to AngularVersion, the attribute case to specialize_bindings_for_host, and the reify case to reify_host. Full builds, partial-then-link and the linker all share this path. Co-Authored-By: Claude Opus 5.5 --- .../src/component/metadata.rs | 73 ++++++ .../pipeline/phases/binding_specialization.rs | 30 +++ .../src/pipeline/phases/reify/mod.rs | 28 +++ .../phases/reify/statements/bindings.rs | 23 ++ .../tests/host_aria_binding_test.rs | 218 ++++++++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 crates/oxc_angular_compiler/tests/host_aria_binding_test.rs diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 9aaf07cf2..934fd423d 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -451,6 +451,33 @@ impl AngularVersion { ]) } + /// Check if a host `[aria-*]` binding becomes an attribute binding when + /// bindings are specialized (v20.3.3+ / v21.0.0-next.5+), so it is emitted + /// as `ɵɵattribute("aria-x", ...)` after the `ɵɵdomProperty` calls. + /// + /// `angular/angular@f008045ded` (#63925, first in 21.0.0-next.5) and its + /// 20.3.x backport `angular/angular@542cd0019a` (#64089, first in 20.3.3). + /// They replace the rule of + /// [`AngularVersion::reifies_host_aria_properties_as_attributes`]. + pub fn specializes_host_aria_attributes(&self) -> bool { + self.fixed_since(&[ + Self::new(20, 3, 3), + Self::new(21, 0, 0).with_prerelease(Prerelease::Next(5)), + ]) + } + + /// Check if a host property binding whose name starts with `aria` (and + /// is longer) is written as `ɵɵattribute(ariaAttrName(name), ...)` when + /// it is reified, in the place of its `ɵɵdomProperty` call + /// (v20.2.0-next.2 to v20.3.2, v21.0.0-next.0 to v21.0.0-next.4). + /// + /// `angular/angular@4138aca91f` (#62630) added it in 20.2.0-next.2; + /// [`AngularVersion::specializes_host_aria_attributes`] replaced it. + pub fn reifies_host_aria_properties_as_attributes(&self) -> bool { + !self.specializes_host_aria_attributes() + && self.cmp_release(&Self::new(20, 2, 0).with_prerelease(Prerelease::Next(2))).is_ge() + } + /// Whether a fix is in this version, given the first fixed release on /// each release line, oldest line first. /// @@ -1259,6 +1286,52 @@ mod tests { } } + /// Host `aria` bindings have three eras: plain DOM properties, then + /// attributes chosen when reified (20.2.0-next.2 to 20.3.2 and + /// 21.0.0-next.0 to next.4), then attributes chosen when bindings are + /// specialized (20.3.3+, 21.0.0-next.5+). + #[test] + fn host_aria_eras_follow_each_release_line() { + let parse = |s| AngularVersion::parse(s).unwrap(); + let era = |s| { + let v = parse(s); + match ( + v.reifies_host_aria_properties_as_attributes(), + v.specializes_host_aria_attributes(), + ) { + (false, false) => 'A', + (true, false) => 'B', + (false, true) => 'C', + (true, true) => panic!("{s} is in two eras"), + } + }; + for (version, expected) in [ + ("19.2.15", 'A'), + ("20.1.8", 'A'), + ("20.2.0-next.0", 'A'), + ("20.2.0-next.1", 'A'), + ("20.2.0-beta.1", 'A'), + ("20.2.0-next.2", 'B'), + ("20.2.0-rc.0", 'B'), + ("20.2.0", 'B'), + ("20.2.4", 'B'), + ("20.3.0", 'B'), + ("20.3.2", 'B'), + ("20.3.3", 'C'), + ("20.3.15", 'C'), + ("21.0.0-next.0", 'B'), + ("21.0.0-next.4", 'B'), + ("21.0.0-next.5", 'C'), + ("21.0.0-rc.0", 'C'), + ("21.0.0", 'C'), + ("21.2.1", 'C'), + ("22.2.4", 'C'), + ("0.0.0-PLACEHOLDER", 'C'), + ] { + assert_eq!(era(version), expected, "{version}"); + } + } + /// Security fixes backported per release line: a version on a listed line /// compares against that line's first fixed release, a newer line has the /// fix, and an unlisted older line (21.0.x, 21.1.x, 22.0.x for host diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs index 8a5d8ac08..989871cc4 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs @@ -422,6 +422,9 @@ pub fn specialize_bindings_for_host(job: &mut HostBindingCompilationJob<'_>) { // Before v20.2 Angular has no `animate.*` bindings: a static `animate.*` // host attribute stays a plain attribute (in `hostAttrs`). let animate = job.angular_version.is_none_or(|v| v.supports_animate_instructions()); + // From v20.3.3 / v21.0.0-next.5 a host job is always DomOnly, so an + // `aria-*` property becomes an attribute binding. + let aria_attributes = job.angular_version.is_none_or(|v| v.specializes_host_aria_attributes()); // Process bindings in the host binding unit let mut cursor = job.root.update.cursor(); @@ -496,6 +499,33 @@ pub fn specialize_bindings_for_host(job: &mut HostBindingCompilationJob<'_>) { cursor.replace_current(new_op); } } + BindingKind::Property | BindingKind::LegacyAnimation + if aria_attributes && is_aria_attribute(name.as_str()) => + { + // Angular checks the DomOnly `aria-*` case before the host + // case, so a host `[aria-x]` (and a legacy `[@aria-x]`, whose + // name has no `@` here) becomes `ɵɵattribute("aria-x", ...)`. + if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() { + let expression = std::mem::replace( + &mut binding.expression, + create_placeholder_expression(allocator), + ); + let new_op = UpdateOp::Attribute(AttributeOp { + base: UpdateOpBase { source_span, ..Default::default() }, + target, + name: binding.name.clone(), + expression, + namespace: None, + security_context, + sanitizer: None, + i18n_context: None, + i18n_message: binding.i18n_message, + is_text_attribute: false, + is_structural_template_attribute: false, + }); + cursor.replace_current(new_op); + } + } binding_kind @ (BindingKind::Property | BindingKind::LegacyAnimation) => { // Host property bindings use DomPropertyOp, which emits ɵɵdomProperty // This matches Angular's binding_specialization.ts for host bindings diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index 23555c67a..20947122c 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -1731,7 +1731,35 @@ pub fn reify_host(job: &mut HostBindingCompilationJob<'_>) { // Reify update operations (property bindings) // Host bindings use Full mode (not DomOnly) + // In v20.2.0-next.2 to v20.3.2 and v21.0.0-next.0 to next.4, an `aria*` + // DOM property is written as `ɵɵattribute(ariaAttrName(name), expr)` in + // its own place (Angular's `reifyDomProperty`). + let aria_properties_as_attributes = + job.angular_version.is_some_and(|v| v.reifies_host_aria_properties_as_attributes()); for op in job.root.update.iter() { + if aria_properties_as_attributes + && let UpdateOp::DomProperty(prop) = op + && !matches!(prop.binding_kind, BindingKind::LegacyAnimation | BindingKind::Animation) + && is_aria_property(prop.name.as_str()) + { + let expr = convert_ir_expression( + allocator, + core_namespace, + &prop.expression, + &job.expressions, + root_xref, + ); + let name = Ident::from(allocator.alloc_str(&aria_attr_name(prop.name.as_str()))); + job.root.update_statements.push(create_attribute_stmt_with_expr( + allocator, + core_namespace, + &name, + expr, + None, + None, + )); + continue; + } let stmt = reify_update_op( &allocator, core_namespace, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs index 33312a90e..6f0a95a87 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs @@ -25,6 +25,29 @@ pub fn is_aria_attribute(name: &str) -> bool { name.starts_with(ARIA_PREFIX) && name.len() > ARIA_PREFIX.len() } +/// Checks if a property name is an ARIA property (or attribute) name: it +/// begins with and is longer than `aria`, like `ariaLabel` or `aria-label`. +/// +/// Port of `isAriaProperty` in Angular 20.2.0-next.2 to 20.3.2 and +/// 21.0.0-next.0 to 21.0.0-next.4 (`reify.ts`). +pub fn is_aria_property(name: &str) -> bool { + name.starts_with("aria") && name.len() > "aria".len() +} + +/// The attribute name of an ARIA property: `ariaLabel` gives `aria-label`, +/// and `aria-label` stays as it is. +/// +/// Port of `ariaAttrName` in Angular 20.2.0-next.2 to 20.3.2 and +/// 21.0.0-next.0 to 21.0.0-next.4 (`reify.ts`). +pub fn aria_attr_name(name: &str) -> std::borrow::Cow<'_, str> { + let rest = &name["aria".len()..]; + if rest.starts_with('-') { + std::borrow::Cow::Borrowed(name) + } else { + std::borrow::Cow::Owned(format!("aria-{}", rest.to_lowercase())) + } +} + /// DOM properties that need to be remapped on the compiler side. /// Note: this mapping has to be kept in sync with the equally named mapping in the Angular runtime. /// See: Angular's `template/pipeline/src/phases/reify.ts` diff --git a/crates/oxc_angular_compiler/tests/host_aria_binding_test.rs b/crates/oxc_angular_compiler/tests/host_aria_binding_test.rs new file mode 100644 index 000000000..517479dcc --- /dev/null +++ b/crates/oxc_angular_compiler/tests/host_aria_binding_test.rs @@ -0,0 +1,218 @@ +//! Host `aria` property bindings (#704). Angular has three eras for them: +//! +//! - A, before 20.2.0-next.2: a DOM property (`ɵɵhostProperty` before 20). +//! - B, 20.2.0-next.2 to 20.3.2 and 21.0.0-next.0 to next.4: when reified, +//! any `aria*` name longer than `aria` is written as +//! `ɵɵattribute(ariaAttrName(name))` in the place of its DOM property. +//! - C, 20.3.3+ and 21.0.0-next.5+: an `aria-*` property becomes an +//! attribute binding, so it comes after the `ɵɵdomProperty` calls. +//! +//! Expected output is ngtsc's (19.2.15, 20.1.8, 20.2.0 and 22.2.4 full +//! builds) and Angular's linker for the other versions. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::linker::{LinkOptions, link_with_options}; +use oxc_angular_compiler::{AngularVersion, TransformOptions, transform_angular_file}; + +fn version(v: &str) -> AngularVersion { + AngularVersion::parse(v).unwrap() +} + +fn compact(code: &str) -> String { + code.chars().filter(|c| !c.is_whitespace()).collect() +} + +fn compile(source: &str, angular_version: Option) -> String { + let allocator = Allocator::default(); + let options = TransformOptions { angular_version, ..Default::default() }; + let result = transform_angular_file(&allocator, "/x/a.ts", source, Some(&options), None); + assert!(result.diagnostics.is_empty(), "unexpected diagnostics: {:?}", result.diagnostics); + compact(&result.code) +} + +fn link(code: &str, angular_version: Option) -> String { + let allocator = Allocator::default(); + let options = LinkOptions { angular_version, ..LinkOptions::default() }; + let result = link_with_options(&allocator, code, "test.mjs", &options); + assert!(result.linked); + compact(&result.code) +} + +/// The update block of the host bindings function. +fn update_block(code: &str) -> &str { + let start = code.find("if((rf&2)){").expect("host update block") + "if((rf&2)){".len(); + let end = code[start..].find('}').unwrap() + start; + &code[start..end] +} + +/// The issue's repro: ngtsc 22.2.4 emits both bindings as attributes. +#[test] +fn host_aria_property_is_an_attribute() { + let code = compile( + "import { Directive, HostBinding } from '@angular/core'; +@Directive({ selector: '[d]', host: { '[aria-label]': 'l' } }) +export class D { l = 'a'; @HostBinding('aria-hidden') h = true; }", + None, + ); + assert!(code.contains("hostVars:2"), "{code}"); + assert_eq!(update_block(&code), r#"i0.ɵɵattribute("aria-label",ctx.l)("aria-hidden",ctx.h);"#); +} + +/// ngtsc 22.2.4 (with host type-checking off: `[aria]` and `[aria-]` are not +/// known properties): only `aria-` and a longer name is an attribute. +#[test] +fn only_aria_dash_names_become_attributes() { + let code = compile( + "import { Component, HostBinding } from '@angular/core'; +@Component({ selector: 'c', template: '', host: { '[aria-label]': 'l', '[attr.aria-x]': 'l', '[ariaLabel]': 'l', '[aria]': 'l', '[aria-]': 'l' } }) +export class C { l = 'a'; @HostBinding('aria-hidden') h = true; }", + None, + ); + assert!(code.contains("hostVars:6"), "{code}"); + assert_eq!( + update_block(&code), + r#"i0.ɵɵdomProperty("ariaLabel",ctx.l)("aria",ctx.l)("aria-",ctx.l);i0.ɵɵattribute("aria-label",ctx.l)("aria-x",ctx.l)("aria-hidden",ctx.h);"# + ); +} + +/// A constant `aria-*` binding stays a binding (ngtsc 22.2.4), like +/// `[attr.title]`; it is not moved to `hostAttrs`. +#[test] +fn constant_aria_binding_is_not_a_host_attribute() { + let code = compile( + "import { Directive } from '@angular/core'; +@Directive({ selector: '[d]', host: { '[aria-label]': \"'x'\", '[attr.title]': \"'y'\", '[ariaLabel]': \"'z'\" } }) +export class D {}", + None, + ); + assert!(code.contains("hostVars:3"), "{code}"); + assert!(!code.contains("hostAttrs"), "{code}"); + assert_eq!( + update_block(&code), + r#"i0.ɵɵdomProperty("ariaLabel","z");i0.ɵɵattribute("aria-label","x")("title","y");"# + ); +} + +/// Angular drops the `@` of a legacy animation before it checks the name, so +/// ngtsc 22.2.4 writes `[@aria-t]` as the attribute `aria-t`. +#[test] +fn legacy_animation_with_an_aria_name_is_an_attribute() { + let code = compile( + "import { Directive } from '@angular/core'; +@Directive({ selector: '[d]', host: { '[@aria-t]': 'l', '[aria-label]': 'l' } }) +export class D { l = 1 }", + None, + ); + assert_eq!(update_block(&code), r#"i0.ɵɵattribute("aria-t",ctx.l)("aria-label",ctx.l);"#); + + let code = compile( + "import { Directive, HostBinding } from '@angular/core'; +@Directive({ selector: '[d]' }) +export class D { @HostBinding('@aria-t') t = 1; @HostBinding('attr.aria-z') z = 1; @HostBinding('ariaLabel') y = 1; }", + None, + ); + assert!(code.contains("hostVars:3"), "{code}"); + assert_eq!( + update_block(&code), + r#"i0.ɵɵdomProperty("ariaLabel",ctx.y);i0.ɵɵattribute("aria-t",ctx.t)("aria-z",ctx.z);"# + ); +} + +/// Angular's compliance golden `host_bindings/aria_bindings`. +#[test] +fn angular_aria_bindings_golden() { + let code = compile( + "import {Component} from '@angular/core'; +@Component({ + template: ``, + host: { + '[attr.aria-disabled]': 'disabled', + '[aria-readonly]': 'readonly', + '[ariaLabel]': 'label', + }, +}) +export class MyComponent { + disabled = ''; + readonly = ''; + label = ''; +}", + None, + ); + assert_eq!( + update_block(&code), + r#"i0.ɵɵdomProperty("ariaLabel",ctx.label);i0.ɵɵattribute("aria-disabled",ctx.disabled)("aria-readonly",ctx.readonly);"# + ); +} + +const ERA_A_19: &str = r#"i0.ɵɵhostProperty("aria-label",ctx.l)("ariaLabel",ctx.l)("aria",ctx.l)("title",ctx.l);i0.ɵɵsyntheticHostProperty("@aria-t",ctx.l);i0.ɵɵattribute("aria-x",ctx.l);"#; +const ERA_A: &str = r#"i0.ɵɵdomProperty("aria-label",ctx.l)("ariaLabel",ctx.l)("aria",ctx.l)("title",ctx.l);i0.ɵɵsyntheticHostProperty("@aria-t",ctx.l);i0.ɵɵattribute("aria-x",ctx.l);"#; +const ERA_B: &str = r#"i0.ɵɵattribute("aria-label",ctx.l)("aria-label",ctx.l);i0.ɵɵdomProperty("aria",ctx.l)("title",ctx.l);i0.ɵɵsyntheticHostProperty("@aria-t",ctx.l);i0.ɵɵattribute("aria-x",ctx.l);"#; +const ERA_C: &str = r#"i0.ɵɵdomProperty("ariaLabel",ctx.l)("aria",ctx.l)("title",ctx.l);i0.ɵɵattribute("aria-label",ctx.l)("aria-x",ctx.l)("aria-t",ctx.l);"#; + +/// The versions of each era, with Angular's output for the host +/// `{ "aria-label", "ariaLabel", "aria", "attr.aria-x", "title", "@aria-t" }`. +const ERAS: &[(&str, &str)] = &[ + ("19.2.15", ERA_A_19), + ("20.1.8", ERA_A), + ("20.2.0-next.0", ERA_A), + ("20.2.0-next.1", ERA_A), + ("20.2.0-next.2", ERA_B), + ("20.2.0", ERA_B), + ("20.2.4", ERA_B), + ("20.3.2", ERA_B), + ("21.0.0-next.4", ERA_B), + ("20.3.3", ERA_C), + ("21.0.0-next.5", ERA_C), + ("21.0.0", ERA_C), + ("22.2.4", ERA_C), +]; + +#[test] +fn full_compile_follows_the_angular_version() { + let source = "import { Directive } from '@angular/core'; +@Directive({ selector: '[d]', host: { '[aria-label]': 'l', '[ariaLabel]': 'l', '[aria]': 'l', '[attr.aria-x]': 'l', '[title]': 'l', '[@aria-t]': 'l' } }) +export class D { l: any; }"; + for &(v, expected) in ERAS { + let code = compile(source, Some(version(v))); + assert_eq!(update_block(&code), expected, "{v}"); + } + assert_eq!(update_block(&compile(source, None)), ERA_C); +} + +#[test] +fn linker_follows_the_angular_version() { + for &(v, expected) in ERAS { + let code = format!( + r#"import * as i0 from "@angular/core"; +class D {{ +}} +D.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "{v}", type: D, isStandalone: true, selector: "[d]", host: {{ properties: {{ "aria-label": "l", "ariaLabel": "l", "aria": "l", "attr.aria-x": "l", "title": "l", "@aria-t": "l" }} }}, ngImport: i0 }}); +"# + ); + let linked = link(&code, Some(version(v))); + assert_eq!(update_block(&linked), expected, "{v}"); + } +} + +/// Era B (Angular 20.3.2's linker): every `aria*` name longer than `aria` is +/// an attribute, a camel-case name lower-cased after `aria-`, kept in its +/// DOM property's place. +#[test] +fn reified_aria_attribute_names() { + let code = r#"import * as i0 from "@angular/core"; +class D { +} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.2", type: D, isStandalone: true, selector: "[d]", host: { properties: { "ariaLabelledByElements": "l", "aria-Busy": "l", "ARIA-x": "l", "aria": "l", "innerHTML": "l", "attr.aria-q": "l" }, listeners: { "click": "b()" } }, ngImport: i0 }); +"#; + let linked = link(code, Some(version("20.3.2"))); + assert_eq!( + update_block(&linked), + r#"i0.ɵɵattribute("aria-labelledbyelements",ctx.l)("aria-Busy",ctx.l);i0.ɵɵdomProperty("ARIA-x",ctx.l)("aria",ctx.l)("innerHTML",ctx.l,i0.ɵɵsanitizeHtml);i0.ɵɵattribute("aria-q",ctx.l);"# + ); + // Era C (22.2.4) for the same host. + let linked = link(code, Some(version("22.2.4"))); + assert_eq!( + update_block(&linked), + r#"i0.ɵɵdomProperty("ariaLabelledByElements",ctx.l)("ARIA-x",ctx.l)("aria",ctx.l)("innerHTML",ctx.l,i0.ɵɵsanitizeHtml);i0.ɵɵattribute("aria-Busy",ctx.l)("aria-q",ctx.l);"# + ); +} From 31dc3607e9ba99fe25a03deb103d7788390d1a05 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:53:48 +0800 Subject: [PATCH 03/22] feat(vite): add augmentDeclarations for .d.ts from a tsc run (#706) A plain `tsc -p tsconfig.lib.json` run writes declarations outside the Vite / Rolldown build, so no plugin hook adds the Ivy members and consumers fail with NG2012. New vite-free entry `@oxc-angular/vite/dts` exports `augmentDeclarations`: - `{ tsconfig }`: loads the user's `typescript` from the tsconfig dir and runs a declaration emit that writes nothing, to get every file tsc writes and its exact path. This covers files only reached by imports (`"files": [entry]`), the common source dir of TS 5.x, `declarationDir`, `.d.mts`, `mapRoot` (the map file path comes from the emit) and `emitBOM`. `compilerOptions` takes what was given to tsc on the command line, with paths from `cwd`. - `{ declarationDir, sources }`: no TypeScript; pairs files with sources by map or by the one layout of the dir (`matchDtsSources`), like the plugin's disk pass. `tsconfig` can be added for `paths` aliases. Each source is compiled in partial mode, as the plugin does. A source with compile errors is reported and its file is left alone. CRLF files keep CRLF. When the file on disk is tsc's text, or tsc's text plus Ivy members, the members are rebuilt from tsc's fresh text, so an incremental tsc run that kept an old file still gets fresh members and a second run changes nothing. `typescript` is an optional peer. The plugin path is not changed. Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/README.md | 70 ++- napi/angular-compiler/package.json | 13 + .../angular-compiler/test/dts-augment.test.ts | 470 ++++++++++++++++ .../vite-plugin/dts-augment.ts | 508 ++++++++++++++++++ 4 files changed, 1055 insertions(+), 6 deletions(-) create mode 100644 napi/angular-compiler/test/dts-augment.test.ts create mode 100644 napi/angular-compiler/vite-plugin/dts-augment.ts diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index c89b3a89a..98b18c5d6 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -57,10 +57,11 @@ const template = compileTemplateSync('
{{ title }}
', 'AppComponent', ' ### Package Exports -| Export | Description | -| ----------------------- | ------------- | -| `@oxc-angular/vite` | Vite plugin | -| `@oxc-angular/vite/api` | Low level API | +| Export | Description | +| ----------------------- | ------------------------------------------------------------------------------------------ | +| `@oxc-angular/vite` | Vite plugin | +| `@oxc-angular/vite/api` | Low level API | +| `@oxc-angular/vite/dts` | `augmentDeclarations`, for `.d.ts` from a `tsc` run ([more](#declarations-from-a-tsc-run)) | ### Core Functions @@ -320,8 +321,9 @@ matched, the build prints a warning that names the dir. Not covered: -- declarations written outside the build, such as a `tsc` run of its own, or - `vite-plugin-dts` `outDirs` outside `build.outDir`; +- declarations written outside the build, such as `vite-plugin-dts` `outDirs` + outside `build.outDir`, or a `tsc` run of its own (see + [the next section](#declarations-from-a-tsc-run)); - several layouts in one output dir without declaration maps (for example two `vite-plugin-dts` `outDirs` side by side): no layout wins, so turn on `declarationMap`; @@ -338,6 +340,62 @@ Files written by another generator (`vite-plugin-dts`) are patched after they are written, so a tool that reads them earlier in the same build sees them without the members. +### Declarations from a `tsc` run + +When the declarations come from a plain `tsc -p tsconfig.lib.json` run, +outside Vite and Rolldown, no plugin hook sees them. Call +`augmentDeclarations` from `@oxc-angular/vite/dts` after `tsc` to add the +members. This entry does not load `vite`. + +```js +// scripts/dts.mjs, run as `tsc -p tsconfig.lib.json && node scripts/dts.mjs` +import { augmentDeclarations } from '@oxc-angular/vite/dts' + +const result = await augmentDeclarations({ tsconfig: 'tsconfig.lib.json' }) +for (const { file, message } of result.errors) console.error(`${file}: ${message}`) +if (result.errors.length > 0 || result.missing.length > 0) process.exitCode = 1 +``` + +With `tsconfig`, the files and their output paths come from TypeScript itself +(the `typescript` package installed for the tsconfig's dir), the same way +`tsc` lays them out. So every file `tsc` wrote is found, also the ones only +reached through imports of a `"files": ["src/public-api.ts"]` tsconfig, and +`declarationDir`, `.d.mts`, `newLine: "crlf"` and `mapRoot` are followed. +Options you gave `tsc` on the command line go in `compilerOptions`, in +tsconfig form, with paths relative to `cwd`: + +```js +// tsc -p tsconfig.json --declaration --emitDeclarationOnly --outDir types +await augmentDeclarations({ tsconfig: 'tsconfig.json', compilerOptions: { outDir: 'types' } }) +``` + +Without TypeScript, pass the output dir and the library's source files +instead. Each declaration file is then matched to its source as in the Vite +build: by its declaration map, else by the one layout of the dir. A file that +matches no source is left alone and listed in `unmatched`. Pass `tsconfig` +too when decorator values come through `paths` aliases: + +```js +await augmentDeclarations({ + declarationDir: 'dist', + sources: ['src/public-api.ts', 'src/lib/foo.component.ts' /* every .ts file */], + tsconfig: 'tsconfig.lib.json', +}) +``` + +The result lists the files it changed (`written`), the ones it paired with a +source (`matched`), and the compiler errors (`errors`); the declaration file +of a source with errors is left as it was. A second run changes nothing. With +`tsconfig` and no `sources`, a file that an incremental `tsc` run did not +write again gets fresh members (a selector held in another file, say), and +`missing` lists the files `tsc` would write that are not there. With +`sources`, members already in a file are kept, so start from a clean output +dir. `outFile` builds are not supported. + +Members are the same text the Vite build writes (the same compiler, in +partial mode). Templates are read as they are, without the plugin's +`templateTransform`. + ## Vite Plugin Architecture The Vite plugin consists of these sub-plugins: diff --git a/napi/angular-compiler/package.json b/napi/angular-compiler/package.json index 64c7095ca..a5cdcc93a 100644 --- a/napi/angular-compiler/package.json +++ b/napi/angular-compiler/package.json @@ -27,6 +27,9 @@ "build-optimizer": [ "./dist/angular-build-optimizer-plugin.d.ts" ], + "dts": [ + "./dist/dts-augment.d.ts" + ], "vite-plugin": [ "./dist/index.d.ts" ] @@ -43,6 +46,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./dts": { + "types": "./dist/dts-augment.d.ts", + "default": "./dist/dts-augment.js" + }, "./api": { "types": "./index.d.ts", "default": "./index.js" @@ -83,8 +90,14 @@ "vitest": "catalog:" }, "peerDependencies": { + "typescript": ">=5.0.0", "vite": ">=8.0.0" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "napi": { "binaryName": "angular-compiler", "packageName": "@oxc-angular/binding", diff --git a/napi/angular-compiler/test/dts-augment.test.ts b/napi/angular-compiler/test/dts-augment.test.ts new file mode 100644 index 000000000..285926fd9 --- /dev/null +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -0,0 +1,470 @@ +import { execFileSync } from 'node:child_process' +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { augmentDeclarations } from '../vite-plugin/dts-augment.js' + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const tscBin = join( + dirname(createRequire(import.meta.url).resolve('typescript/package.json')), + 'bin/tsc', +) + +// The library: every file reached from the entry, which is the only file +// the tsconfig lists (`tsc` still writes a `.d.ts` for each of them). +const SOURCES: Record = { + 'src/public-api.ts': `export * from './lib/foo.component'; +export * from './lib/bar.directive'; +export * from './lib/upper.pipe'; +export * from './lib/foo.module'; +export * from './lib/sel.directive'; +export * from './lib/util'; +`, + 'src/lib/bar.directive.ts': `import { Directive, Input, HostBinding } from '@angular/core'; + +@Directive({ selector: '[libBar]', standalone: true }) +export class BarDirective { + @Input() barColor = 'red'; + @HostBinding('style.color') get color() { return this.barColor; } +} +`, + 'src/lib/upper.pipe.ts': `import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'upper' }) +export class UpperPipe implements PipeTransform { + transform(v: string): string { return v.toUpperCase(); } +} +`, + 'src/lib/foo.component.ts': `import { Component, Input, input, output, booleanAttribute } from '@angular/core'; + +@Component({ + selector: 'lib-foo', + templateUrl: './foo.component.html', + exportAs: 'libFoo', +}) +export class FooComponent { + @Input({ transform: booleanAttribute }) disabled = false; + @Input({ required: true }) label!: string; + readonly size = input(1); + readonly changed = output(); +} +`, + 'src/lib/foo.component.html': `{{ label }}\n`, + 'src/lib/foo.module.ts': `import { NgModule } from '@angular/core'; +import { FooComponent } from './foo.component'; +import { UpperPipe } from './upper.pipe'; + +@NgModule({ imports: [FooComponent, UpperPipe], exports: [FooComponent, UpperPipe] }) +export class FooModule {} +`, + 'src/lib/consts.ts': `export const SEL: string = '[libSel]';\n`, + 'src/lib/sel.directive.ts': `import { Directive } from '@angular/core'; +import { SEL } from './consts'; + +@Directive({ selector: SEL }) +export class SelDirective {} +`, + 'src/lib/util.ts': `export class Helper { + x = 1; +} +`, +} + +const TSCONFIG = { + compilerOptions: { + target: 'ES2022', + module: 'ES2022', + moduleResolution: 'bundler', + rootDir: 'src', + outDir: 'dist', + declaration: true, + declarationMap: true, + skipLibCheck: true, + strict: true, + experimentalDecorators: true, + useDefineForClassFields: false, + lib: ['es2022', 'dom'], + }, + angularCompilerOptions: { compilationMode: 'partial' }, + files: ['src/public-api.ts'], +} + +// What ngc 22.2 (`ngc -p tsconfig.json`) writes for the library above. +const NGC: Record = { + 'lib/bar.directive.d.ts': `import * as i0 from "@angular/core"; +export declare class BarDirective { + barColor: string; + get color(): string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +//# sourceMappingURL=bar.directive.d.ts.map`, + 'lib/consts.d.ts': `export declare const SEL: string; +//# sourceMappingURL=consts.d.ts.map`, + 'lib/foo.component.d.ts': `import * as i0 from "@angular/core"; +export declare class FooComponent { + disabled: boolean; + label: string; + readonly size: import("@angular/core").InputSignal; + readonly changed: import("@angular/core").OutputEmitterRef; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; + static ngAcceptInputType_disabled: unknown; +} +//# sourceMappingURL=foo.component.d.ts.map`, + 'lib/foo.module.d.ts': `import * as i0 from "@angular/core"; +import * as i1 from "./foo.component"; +import * as i2 from "./upper.pipe"; +export declare class FooModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +//# sourceMappingURL=foo.module.d.ts.map`, + 'lib/sel.directive.d.ts': `import * as i0 from "@angular/core"; +export declare class SelDirective { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +//# sourceMappingURL=sel.directive.d.ts.map`, + 'lib/upper.pipe.d.ts': `import { PipeTransform } from '@angular/core'; +import * as i0 from "@angular/core"; +export declare class UpperPipe implements PipeTransform { + transform(v: string): string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵpipe: i0.ɵɵPipeDeclaration; +} +//# sourceMappingURL=upper.pipe.d.ts.map`, + 'lib/util.d.ts': `export declare class Helper { + x: number; +} +//# sourceMappingURL=util.d.ts.map`, + 'public-api.d.ts': `export * from './lib/foo.component'; +export * from './lib/bar.directive'; +export * from './lib/upper.pipe'; +export * from './lib/foo.module'; +export * from './lib/sel.directive'; +export * from './lib/util'; +//# sourceMappingURL=public-api.d.ts.map`, +} + +// `tsc`'s map of `lib/bar.directive.d.ts`, and the same map once the import +// line and the two members are in (original tokens keep their mapping). +const TSC_BAR_MAPPINGS = 'AAEA,qBACa,YAAY;IACd,QAAQ,SAAS;IAC1B,IAAgC,KAAK,WAA4B;CAClE' +const AUGMENTED_BAR_MAPPINGS = ';AAEA,qBACa,YAAY;IACd,QAAQ,SAAS;IAC1B,IAAgC,KAAK,WAA4B;;;CAClE' + +const ANGULAR_FILES = [ + 'lib/bar.directive.d.ts', + 'lib/foo.component.d.ts', + 'lib/foo.module.d.ts', + 'lib/sel.directive.d.ts', + 'lib/upper.pipe.d.ts', +] + +let root: string + +/** A fresh copy of the library in `root/`. */ +function project(name: string): string { + const dir = join(root, name) + for (const [file, text] of Object.entries(SOURCES)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }) + writeFileSync(join(dir, file), text) + } + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(TSCONFIG, null, 2)) + return dir +} + +function tsc(dir: string, ...args: string[]): void { + execFileSync(process.execPath, [tscBin, ...args], { cwd: dir, stdio: 'pipe' }) +} + +function read(file: string): string { + return readFileSync(file, 'utf8') +} + +/** Every file under `dir`, by path from `dir`, with its text. */ +function snapshot(dir: string): Record { + const out: Record = {} + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) continue + const file = join(entry.parentPath, entry.name) + out[relative(dir, file)] = read(file) + } + return out +} + +function mappings(file: string): string { + return JSON.parse(read(file)).mappings +} + +beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'oxc-angular-dts-augment-'))) + // `typescript` and `@angular/core` for the projects, as a library has them. + symlinkSync(join(packageDir, 'node_modules'), join(root, 'node_modules'), 'junction') +}) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('augmentDeclarations with a tsconfig', () => { + it("gives ngc's declaration files for a tsc build, also for files only reached by imports", async () => { + const dir = project('basic') + tsc(dir, '-p', 'tsconfig.json') + const dist = join(dir, 'dist') + expect(mappings(join(dist, 'lib/bar.directive.d.ts.map'))).toBe(TSC_BAR_MAPPINGS) + const plain = snapshot(dist) + + const result = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + + expect(result.errors).toEqual([]) + expect(result.missing).toEqual([]) + expect(result.unmatched).toEqual([]) + expect(result.written.map((f) => relative(dist, f)).sort()).toEqual(ANGULAR_FILES) + expect(result.matched).toHaveLength(Object.keys(NGC).length) + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dist, file)), file).toBe(text) + } + // Files without Angular classes are left as tsc wrote them. + expect(read(join(dist, 'lib/util.d.ts'))).toBe(plain['lib/util.d.ts']) + expect(read(join(dist, 'public-api.d.ts'))).toBe(plain['public-api.d.ts']) + // The map is shifted to fit the new lines. + expect(mappings(join(dist, 'lib/bar.directive.d.ts.map'))).toBe(AUGMENTED_BAR_MAPPINGS) + + // A second run changes nothing. + const once = snapshot(dist) + const again = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + expect(again.written).toEqual([]) + expect(again.errors).toEqual([]) + expect(snapshot(dist)).toEqual(once) + }) + + it('takes options given to tsc on the command line, relative to cwd', async () => { + const dir = project('cli') + const { declaration, declarationMap, outDir, ...rest } = TSCONFIG.compilerOptions + expect([declaration, declarationMap, outDir]).toEqual([true, true, 'dist']) + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ ...TSCONFIG, compilerOptions: rest }, null, 2), + ) + tsc(dir, '-p', 'tsconfig.json', '--declaration', '--emitDeclarationOnly', '--outDir', 'types') + + const result = await augmentDeclarations({ + tsconfig: 'tsconfig.json', + compilerOptions: { outDir: 'types' }, + cwd: dir, + }) + + expect(result.errors).toEqual([]) + expect(result.missing).toEqual([]) + expect(result.written).toHaveLength(ANGULAR_FILES.length) + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dir, 'types', file)), file).toBe( + text.replace(/\n\/\/# sourceMappingURL=.*$/, '\n'), + ) + } + + // `declarationDir` is the same as `compilerOptions.declarationDir`. + tsc( + dir, + '-p', + 'tsconfig.json', + '--declaration', + '--emitDeclarationOnly', + '--declarationDir', + 'dd', + ) + const byDir = await augmentDeclarations({ + tsconfig: 'tsconfig.json', + declarationDir: 'dd', + cwd: dir, + }) + expect(byDir.written.map((f) => relative(join(dir, 'dd'), f)).sort()).toEqual(ANGULAR_FILES) + }) + + it('keeps CRLF line endings', async () => { + const dir = project('crlf') + tsc(dir, '-p', 'tsconfig.json', '--newLine', 'crlf') + + const result = await augmentDeclarations({ + tsconfig: join(dir, 'tsconfig.json'), + compilerOptions: { newLine: 'crlf' }, + }) + + expect(result.errors).toEqual([]) + // ngc writes the same text with `\r\n`. + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dir, 'dist', file)), file).toBe(text.replace(/\n/g, '\r\n')) + } + expect(mappings(join(dir, 'dist/lib/bar.directive.d.ts.map'))).toBe(AUGMENTED_BAR_MAPPINGS) + }) + + it('keeps the byte order mark of emitBOM', async () => { + const dir = project('bom') + tsc(dir, '-p', 'tsconfig.json', '--emitBOM') + + const result = await augmentDeclarations({ + tsconfig: join(dir, 'tsconfig.json'), + compilerOptions: { emitBOM: true }, + }) + + expect(result.written).toHaveLength(ANGULAR_FILES.length) + // ngc writes the same text after the mark. + const bom = String.fromCharCode(0xfeff) + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dir, 'dist', file)), file).toBe(bom + text) + } + }) + + it('shifts the map when mapRoot makes the map comment a URL', async () => { + const dir = project('map-root') + tsc(dir, '-p', 'tsconfig.json', '--mapRoot', 'https://example.com/maps/') + + await augmentDeclarations({ + tsconfig: join(dir, 'tsconfig.json'), + compilerOptions: { mapRoot: 'https://example.com/maps/' }, + }) + + const bar = read(join(dir, 'dist/lib/bar.directive.d.ts')) + expect(bar).toBe( + NGC['lib/bar.directive.d.ts'].replace( + 'sourceMappingURL=', + 'sourceMappingURL=https://example.com/maps/lib/', + ), + ) + expect(mappings(join(dir, 'dist/lib/bar.directive.d.ts.map'))).toBe(AUGMENTED_BAR_MAPPINGS) + }) + + it('replaces members an incremental tsc run left in place', async () => { + const dir = project('incremental') + const tsconfig = join(dir, 'tsconfig.json') + writeFileSync( + tsconfig, + JSON.stringify({ + ...TSCONFIG, + compilerOptions: { + ...TSCONFIG.compilerOptions, + incremental: true, + tsBuildInfoFile: 'dist/.tsbuildinfo', + }, + }), + ) + tsc(dir, '-p', 'tsconfig.json') + await augmentDeclarations({ tsconfig }) + const sel = join(dir, 'dist/lib/sel.directive.d.ts') + expect(read(sel)).toBe(NGC['lib/sel.directive.d.ts']) + + // The selector's type stays `string`, so tsc does not write the + // directive's file again: its members still say `[libSel]`. + writeFileSync(join(dir, 'src/lib/consts.ts'), `export const SEL: string = '[other]';\n`) + tsc(dir, '-p', 'tsconfig.json') + expect(read(sel)).toBe(NGC['lib/sel.directive.d.ts']) + + const result = await augmentDeclarations({ tsconfig }) + expect(result.written).toEqual([sel]) + // ngc writes `"[other]"`. + expect(read(sel)).toBe(NGC['lib/sel.directive.d.ts'].replace('"[libSel]"', '"[other]"')) + }) + + it('lists the declaration files that are not there yet', async () => { + const dir = project('missing') + const result = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + expect(result.written).toEqual([]) + expect(result.matched).toEqual([]) + expect(result.missing.map((f) => relative(join(dir, 'dist'), f)).sort()).toEqual( + Object.keys(NGC).sort(), + ) + }) + + it('reports compile errors and leaves that file alone', async () => { + const dir = project('error') + rmSync(join(dir, 'src/lib/foo.component.html')) + tsc(dir, '-p', 'tsconfig.json') + const plain = read(join(dir, 'dist/lib/foo.component.d.ts')) + + const result = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].file).toBe(join(dir, 'src/lib/foo.component.ts')) + expect(result.errors[0].message).toContain('COMPONENT_RESOURCE_NOT_FOUND') + expect(read(join(dir, 'dist/lib/foo.component.d.ts'))).toBe(plain) + expect(read(join(dir, 'dist/lib/bar.directive.d.ts'))).toBe(NGC['lib/bar.directive.d.ts']) + }) +}) + +describe('augmentDeclarations with sources', () => { + const sources = Object.keys(SOURCES).filter((file) => file.endsWith('.ts')) + + it('matches files without maps by the layout tsc used, without TypeScript', async () => { + const dir = project('sources') + tsc(dir, '-p', 'tsconfig.json', '--declarationMap', 'false') + // A file whose source is not one of the sources: never touched. + const stray = join(dir, 'dist/testing/bar.directive.d.ts') + mkdirSync(dirname(stray), { recursive: true }) + cpSync(join(dir, 'dist/lib/bar.directive.d.ts'), stray) + const strayText = read(stray) + + const result = await augmentDeclarations({ declarationDir: 'dist', sources, cwd: dir }) + + expect(result.errors).toEqual([]) + expect(result.unmatched).toEqual([stray]) + expect(result.written.map((f) => relative(join(dir, 'dist'), f)).sort()).toEqual(ANGULAR_FILES) + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dir, 'dist', file)), file).toBe( + text.replace(/\n\/\/# sourceMappingURL=.*$/, '\n'), + ) + } + expect(read(stray)).toBe(strayText) + + const again = await augmentDeclarations({ declarationDir: 'dist', sources, cwd: dir }) + expect(again.written).toEqual([]) + }) + + it('compiles with the tsconfig when one is given (paths aliases)', async () => { + const dir = project('paths') + const tsconfig = { + ...TSCONFIG, + compilerOptions: { ...TSCONFIG.compilerOptions, paths: { '@lib/*': ['./src/lib/*'] } }, + } + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig)) + const selFile = join(dir, 'src/lib/sel.directive.ts') + writeFileSync(selFile, read(selFile).replace("'./consts'", "'@lib/consts'")) + tsc(dir, '-p', 'tsconfig.json') + const plain = read(join(dir, 'dist/lib/sel.directive.d.ts')) + + // Without the tsconfig the alias can't be followed: an error, and the + // file keeps no wrong members. + const without = await augmentDeclarations({ declarationDir: 'dist', sources, cwd: dir }) + expect(without.errors.map((e) => e.file)).toEqual([selFile]) + expect(read(join(dir, 'dist/lib/sel.directive.d.ts'))).toBe(plain) + + const withTsconfig = await augmentDeclarations({ + tsconfig: 'tsconfig.json', + declarationDir: 'dist', + sources, + cwd: dir, + }) + expect(withTsconfig.errors).toEqual([]) + expect(read(join(dir, 'dist/lib/sel.directive.d.ts'))).toBe(NGC['lib/sel.directive.d.ts']) + }) + + it('needs declarationDir', async () => { + await expect(augmentDeclarations({ sources })).rejects.toThrow(/declarationDir/) + await expect(augmentDeclarations({})).rejects.toThrow(/tsconfig/) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/dts-augment.ts b/napi/angular-compiler/vite-plugin/dts-augment.ts new file mode 100644 index 000000000..00d086905 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/dts-augment.ts @@ -0,0 +1,508 @@ +/** + * Add Angular's Ivy members (`static ɵfac`, `static ɵcmp`, …) to declaration + * files that a separate `tsc` run wrote, outside any Vite / Rolldown build. + * + * ngc adds these members while TypeScript writes the declarations (an + * `afterDeclarations` transform). A plain `tsc` run has no such step, so this + * module adds them afterwards to the files `tsc` wrote: each file gets the + * members of its own source file, never matched by class name, with the same + * splice code (`injectDtsFile`) the Vite plugin uses. + * + * This file must not import `vite`: it is the `@oxc-angular/vite/dts` entry, + * meant for plain Node scripts. + */ +import { readFileSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' + +import { + extractComponentUrls, + transformAngularFile, + type AngularVersion, + type ResolvedResources, + type TransformOptions, +} from '#binding' +import type * as TS from 'typescript' + +import { installedAngularVersion, parseAngularVersion } from './utils/angular-version.js' +import { isFileSystemCaseSensitive } from './utils/case-sensitive.js' +import { + dtsMapSource, + findDtsFiles, + injectDtsFile, + isProjectTsSource, + matchDtsSources, + type DtsClassDeclaration, +} from './utils/dts.js' + +export type { AngularVersion } from '#binding' + +/** Options of {@link augmentDeclarations}. */ +export interface AugmentDeclarationsOptions { + /** + * The tsconfig the declarations were built with (`tsc -p `). + * + * Without `sources`, the files and output paths come from TypeScript + * itself (the `typescript` package installed for the tsconfig's dir), so + * every file `tsc` wrote is found, including files only reached through + * imports. With `sources`, it is only used to compile them (`paths` + * aliases, `extends`), and TypeScript is not loaded. + */ + tsconfig?: string + /** + * Compiler options that were given to `tsc` on the command line, in + * tsconfig form, such as `{ outDir: 'types' }` for + * `tsc -p tsconfig.json --outDir types`. Relative paths are taken from + * `cwd`, as `tsc` does. Only used with `tsconfig` and no `sources`. + * `declaration` and `emitDeclarationOnly` are always on. + */ + compilerOptions?: Record + /** + * The dir that holds the declaration files, relative to `cwd`. + * + * With `tsconfig` and no `sources`: the same as + * `compilerOptions.declarationDir`. With `sources`: the dir to scan. + */ + declarationDir?: string + /** + * The source files of the library (relative to `cwd` or absolute). With + * this, TypeScript is not loaded: each declaration file under + * `declarationDir` is matched to one of these by its declaration map, or, + * without maps, by the one layout `tsc` used for the whole dir. A file + * that matches no source is left alone and listed in `unmatched`. List + * every TS file of the library, not only the Angular ones, so a plain + * `util.ts` claims its own `util.d.ts`. + */ + sources?: readonly string[] + /** + * The Angular version to write members for, as an object or a string + * (`'20.1.0'`). Default: the `@angular/core` installed for the tsconfig's + * dir (or `cwd`). + */ + angularVersion?: AngularVersion | string + /** The dir relative paths are taken from. Default: `process.cwd()`. */ + cwd?: string +} + +/** A compiler error. The declaration file of `file` was left as it was. */ +export interface AugmentDeclarationsError { + file: string + message: string +} + +/** What {@link augmentDeclarations} did. All paths are absolute. */ +export interface AugmentDeclarationsResult { + /** Declaration files that were changed (their maps were kept in step). */ + written: string[] + /** Declaration files that were paired with a source file, changed or not. */ + matched: string[] + /** With `sources`: declaration files whose source is not one of them. */ + unmatched: string[] + /** Without `sources`: declaration files `tsc` would write that are not there. */ + missing: string[] + /** Compiler errors; the declaration files of these sources were not touched. */ + errors: AugmentDeclarationsError[] +} + +/** One declaration file and the source it was written from. */ +interface Target { + file: string + source: string + /** + * Without `sources`: what `tsc` writes for the file now (the text, and + * the map file with its text), from an in-memory declaration emit. + */ + emitted?: { code: string; mapFile?: string; map?: string } +} + +/** The options each source file is compiled with. */ +interface CompileContext { + tsconfigPath?: string + rootDirs: string[] + caseSensitive: boolean + angularVersion?: AngularVersion +} + +/** + * Add Angular's Ivy members to the declaration files a `tsc` run wrote: + * + * ```ts + * import { augmentDeclarations } from '@oxc-angular/vite/dts' + * + * // after `tsc -p tsconfig.lib.json` + * const result = await augmentDeclarations({ tsconfig: 'tsconfig.lib.json' }) + * if (result.errors.length > 0) process.exitCode = 1 + * ``` + * + * Each declaration file gets the members of its own source file, plus the + * `import * as i0 from "@angular/core"` imports they use, and its + * declaration map is shifted to fit. Running it twice changes nothing the + * second time. A source that fails to compile is reported in `errors` and + * its declaration file is left alone. + */ +export async function augmentDeclarations( + options: AugmentDeclarationsOptions, +): Promise { + const cwd = resolve(options.cwd ?? process.cwd()) + const tsconfig = options.tsconfig === undefined ? undefined : resolve(cwd, options.tsconfig) + const angularVersion = + typeof options.angularVersion === 'string' + ? parseAngularVersion(options.angularVersion) + : options.angularVersion + if (typeof options.angularVersion === 'string' && !angularVersion) { + throw new Error(`augmentDeclarations: "${options.angularVersion}" is not a version.`) + } + + const result: AugmentDeclarationsResult = { + written: [], + matched: [], + unmatched: [], + missing: [], + errors: [], + } + + let targets: Target[] + let context: CompileContext + if (options.sources) { + if (options.declarationDir === undefined) { + throw new Error('augmentDeclarations: `sources` needs `declarationDir`.') + } + const caseSensitive = isFileSystemCaseSensitive(cwd) + targets = await matchedTargets( + resolve(cwd, options.declarationDir), + options.sources.map((source) => resolve(cwd, source)), + caseSensitive, + result, + ) + context = { + tsconfigPath: tsconfig, + rootDirs: [cwd], + caseSensitive, + angularVersion: angularVersion ?? installedAngularVersion(tsconfig ? dirname(tsconfig) : cwd), + } + } else if (tsconfig) { + const ts = loadTypeScript(tsconfig) + const project = emittedTargets(ts, tsconfig, cwd, options) + targets = project.targets + result.errors.push(...project.errors) + context = { + tsconfigPath: tsconfig, + rootDirs: [project.rootDir ?? dirname(tsconfig)], + caseSensitive: ts.sys.useCaseSensitiveFileNames, + angularVersion: angularVersion ?? installedAngularVersion(dirname(tsconfig)), + } + } else { + throw new Error('augmentDeclarations: pass `tsconfig`, or `declarationDir` and `sources`.') + } + + for (const target of targets) { + await augmentTarget(target, context, result) + } + return result +} + +/** + * The `typescript` package installed for the tsconfig's dir (the one `tsc` + * came from), else the one this package can see. + */ +function loadTypeScript(tsconfig: string): typeof TS { + for (const from of [tsconfig, import.meta.url]) { + try { + return createRequire(from)('typescript') as typeof TS + } catch { + // try the next place + } + } + throw new Error( + `augmentDeclarations: the \`typescript\` package was not found from ${dirname(tsconfig)}. ` + + 'Install it, or pass `declarationDir` and `sources` instead.', + ) +} + +/** + * Every declaration file `tsc -p tsconfig` writes, with its source file, + * from a declaration emit of the whole program that writes nothing. The + * program holds the files reached through imports too, and the emit lays + * them out exactly as `tsc` does (common source dir, `declarationDir`, + * `.d.mts`, `mapRoot`, `stripInternal`). + */ +function emittedTargets( + ts: typeof TS, + tsconfig: string, + cwd: string, + options: AugmentDeclarationsOptions, +): { targets: Target[]; errors: AugmentDeclarationsError[]; rootDir?: string } { + const formatHost: TS.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => cwd, + getNewLine: () => '\n', + } + const describe = (diagnostics: readonly TS.Diagnostic[]) => + ts.formatDiagnostics(diagnostics, formatHost).trim() + + const json: Record = { ...options.compilerOptions } + if (options.declarationDir !== undefined) json.declarationDir = options.declarationDir + const converted = ts.convertCompilerOptionsFromJson(json, cwd) + if (converted.errors.length > 0) { + throw new Error(`augmentDeclarations: ${describe(converted.errors)}`) + } + const optionsToExtend: TS.CompilerOptions = { + ...converted.options, + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + noEmitOnError: false, + } + + const host: TS.ParseConfigFileHost = { + ...ts.sys, + getCurrentDirectory: () => cwd, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(`augmentDeclarations: ${describe([diagnostic])}`) + }, + } + const config = ts.getParsedCommandLineOfConfigFile(tsconfig, optionsToExtend, host) + if (!config) throw new Error(`augmentDeclarations: cannot read ${tsconfig}.`) + if (config.options.outFile) { + throw new Error( + 'augmentDeclarations: `outFile` (one bundled declaration file) is not supported.', + ) + } + const errors = config.errors + .filter((d) => d.category === ts.DiagnosticCategory.Error) + .map((d) => ({ file: tsconfig, message: describe([d]) })) + + const program = ts.createProgram({ + rootNames: config.fileNames, + options: config.options, + projectReferences: config.projectReferences, + }) + const bySource = new Map() + program.emit( + undefined, + (fileName, text, writeByteOrderMark, _onError, sourceFiles) => { + // `sourceFiles` holds the transformed declaration nodes; their + // `fileName` is still the source file's. + const emittedFrom = sourceFiles?.length === 1 ? sourceFiles[0] : undefined + const sourceFile = emittedFrom && program.getSourceFile(emittedFrom.fileName) + if (!sourceFile || sourceFile.isDeclarationFile) return + if (program.isSourceFileFromExternalLibrary(sourceFile)) return + let target = bySource.get(sourceFile.fileName) + if (!target) { + target = { file: '', source: resolve(sourceFile.fileName), emitted: { code: '' } } + bySource.set(sourceFile.fileName, target) + } + const code = writeByteOrderMark ? BOM + text : text + if (fileName.endsWith('.map')) { + target.emitted!.mapFile = resolve(cwd, fileName) + target.emitted!.map = code + } else { + target.file = resolve(cwd, fileName) + target.emitted!.code = code + } + }, + undefined, + /* emitOnlyDtsFiles */ true, + ) + const targets = [...bySource.values()].filter( + (target) => target.file && isProjectTsSource(target.source), + ) + return { targets, errors, rootDir: config.options.rootDir } +} + +/** + * The declaration files under `dir`, each paired with its source among + * `sources` the way the Vite plugin pairs the files a generator wrote: + * by declaration map, else by the one layout of the dir. + */ +async function matchedTargets( + dir: string, + sources: string[], + caseSensitive: boolean, + result: AugmentDeclarationsResult, +): Promise { + const files: Array<{ fileName: string; mapSource?: string }> = [] + for (const file of await findDtsFiles(dir)) { + const code = await readFile(file, 'utf8') + const mapSource = dtsMapSource(code, file, (ref) => readTextSync(resolve(dirname(file), ref))) + files.push({ fileName: file, mapSource }) + } + const matched = matchDtsSources(files, sources, caseSensitive) + const targets: Target[] = [] + for (const [i, { fileName }] of files.entries()) { + const source = matched[i] + if (source) targets.push({ file: fileName, source }) + else result.unmatched.push(fileName) + } + return targets +} + +/** Add the members of `target.source` to `target.file`, when they are new. */ +async function augmentTarget( + target: Target, + context: CompileContext, + result: AugmentDeclarationsResult, +): Promise { + const { file, emitted } = target + const disk = await readText(file) + if (disk === undefined) { + result.missing.push(file) + return + } + result.matched.push(file) + + const declarations = await compile(target.source, context, result.errors) + if (!declarations) return + + // Start from what `tsc` writes now when the file on disk is that text, + // or that text plus Ivy members from an earlier run: an incremental `tsc` + // leaves a file alone when its own text did not change, even when its + // members did (a selector held in another file). Anything else on disk + // (`tsc` run with other options than these) is patched as it is, and + // members already in it are kept. + let base = disk + let mapFile: string | undefined + let baseMap: string | undefined + if (emitted) { + mapFile = emitted.mapFile + if (disk === emitted.code || sameApartFromIvyMembers(disk, emitted.code)) { + base = emitted.code + baseMap = emitted.map + } else { + baseMap = mapFile && (await readText(mapFile)) + } + } + + // tsc writes `\r\n` with `newLine: "crlf"`; the splice works on `\n` + // lines. Lines stay where they are, so the map is the same either way. + const bom = base.startsWith(BOM) ? BOM : '' + const crlf = base.includes('\r\n') + let code = base.slice(bom.length) + if (crlf) code = code.replace(/\r\n/g, '\n') + let map = baseMap + if (declarations.length > 0) { + // With a known map file (`mapRoot` makes the comment a URL), use it. + const readMap = emitted + ? () => baseMap + : (ref: string) => readTextSync(resolve(dirname(file), ref)) + const injected = injectDtsFile(code, declarations, file, readMap) + if (injected) { + code = injected.code + if (injected.map) { + map = injected.map.json + mapFile ??= resolve(dirname(file), injected.map.ref) + } + } + } + if (crlf) code = code.replace(/\n/g, '\r\n') + code = bom + code + + if (code !== disk) { + await writeFile(file, code) + result.written.push(file) + } + if (mapFile && map !== undefined && map !== (await readText(mapFile))) { + await writeFile(mapFile, map) + } +} + +/** + * The `.d.ts` members of the Angular classes in `source`, compiled as the + * Vite plugin compiles a library (`compilationMode: 'partial'`), or + * `undefined` when the compiler reports errors (pushed to `errors`). + */ +async function compile( + source: string, + context: CompileContext, + errors: AugmentDeclarationsError[], +): Promise { + let code: string + try { + code = await readFile(source, 'utf8') + } catch (e) { + errors.push({ file: source, message: `cannot read the file: ${(e as Error).message}` }) + return undefined + } + // Ivy members only go on decorated classes. + if (!code.includes('@')) return [] + + const options: TransformOptions = { + compilationMode: 'partial', + angularVersion: context.angularVersion, + rootDirs: context.rootDirs, + useCaseSensitiveFileNames: context.caseSensitive, + resolveImportedValues: true, + baseDir: dirname(source), + tsconfigPath: context.tsconfigPath, + } + + // Templates can change the members (`ngContentSelectors`), so they are + // read as they are. No member uses style text, so styles stay empty. + const { templateUrls, styleUrls } = await extractComponentUrls(code, source, options) + const templates: Record = {} + for (const url of templateUrls) { + const text = await readText(resolve(dirname(source), url)) + if (text !== undefined) templates[url] = text + } + const styles: Record = {} + for (const url of styleUrls) styles[url] = [''] + const resources = { templates, styles } as unknown as ResolvedResources + + let output = await transformAngularFile(code, source, options, resources) + // A template `` is one more stylesheet. + if (output.missingLinkUrls.length > 0) { + for (const url of output.missingLinkUrls) styles[url] = [''] + output = await transformAngularFile(code, source, options, resources) + } + if (output.errors.length > 0) { + for (const error of output.errors) { + errors.push({ + file: source, + message: error.helpMessage ? `${error.message}\n${error.helpMessage}` : error.message, + }) + } + return undefined + } + return output.dtsDeclarations.map((decl) => ({ + className: decl.className, + members: decl.members, + namespaceImports: decl.namespaceImports, + sourceFile: source, + })) +} + +// A byte order mark, which `tsc` writes with `emitBOM`. +const BOM = String.fromCharCode(0xfeff) + +// The lines the splice adds: Ivy statics, and the namespace imports they use. +const IVY_MEMBER_LINE = + /^\s*static (?:ɵ(?:fac|cmp|dir|pipe|mod|inj|prov)|ngAcceptInputType_[^\s:?]+)\??:/ +const NAMESPACE_IMPORT_LINE = /^import \* as i\d+(?:_\d+)* from (["']).*\1;$/ + +/** Whether `a` and `b` are the same text once the lines the splice adds are removed. */ +function sameApartFromIvyMembers(a: string, b: string): boolean { + const strip = (text: string) => + text + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !IVY_MEMBER_LINE.test(line) && !NAMESPACE_IMPORT_LINE.test(line)) + .join('\n') + return strip(a) === strip(b) +} + +async function readText(file: string): Promise { + try { + return await readFile(file, 'utf8') + } catch { + return undefined + } +} + +function readTextSync(file: string): string | undefined { + try { + return readFileSync(file, 'utf8') + } catch { + return undefined + } +} From e63657e64a8a389cb451fa99e694596f0c3a9dde Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 20:57:02 +0800 Subject: [PATCH 04/22] fix: align template animate.* with ngtsc across versions (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Before v20.2, `animate.*` in a template is plain: a static attribute stays in consts, `[animate.x]` is a property, `(animate.x)` a listener and `[attr.animate.x]` an attribute. Host `(animate.x)` listeners are plain `ɵɵlistener` too. This matches ngtsc 19.2 and 20.1. - On v20.2.0 to v20.3.0 a template `animate.leave` adds `ɵɵAnimationsFeature()` (Angular's `analyzeTemplateForAnimations`), in full compile, the linker and the HMR update module. `[class.animate.leave]` does not count, `[@animate.leave]` does, as in Angular. - Animate ops go right after their element one at a time in update order, so the last binding comes first, as ngtsc emits them. - From v20.2, `[(animate.x)]` and `bindon-animate.x` are animation bindings with an animation listener, as in ngtsc. Co-Authored-By: Claude Opus 5.5 --- .../src/component/definition.rs | 190 ++++++++--- .../src/component/transform.rs | 28 ++ .../src/directive/metadata.rs | 47 +++ .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../src/hmr/update_module.rs | 18 +- crates/oxc_angular_compiler/src/linker/mod.rs | 15 +- .../src/pipeline/ingest.rs | 14 +- .../pipeline/phases/binding_specialization.rs | 11 +- .../src/pipeline/phases/convert_animations.rs | 241 +++----------- .../src/transform/html_to_r3.rs | 52 ++- .../tests/hmr_component_update_test.rs | 49 +++ .../tests/integration_test.rs | 302 ++++++++++++++++++ .../oxc_angular_compiler/tests/linker_test.rs | 78 +++++ napi/angular-compiler/src/lib.rs | 1 + 14 files changed, 789 insertions(+), 258 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/definition.rs b/crates/oxc_angular_compiler/src/component/definition.rs index 2e5e61d96..59f333d7b 100644 --- a/crates/oxc_angular_compiler/src/component/definition.rs +++ b/crates/oxc_angular_compiler/src/component/definition.rs @@ -73,6 +73,7 @@ pub fn generate_component_definitions<'a>( view_query_fn: Option>, content_queries_fn: Option>, namespace_registry: &mut NamespaceRegistry<'a>, + template_animations_feature: bool, ) -> ComponentDefinitions<'a> { // IMPORTANT: Generate ɵfac BEFORE ɵcmp to match Angular's namespace index assignment order. // Angular processes results in order [fac, def, ...] during the transform phase @@ -93,6 +94,7 @@ pub fn generate_component_definitions<'a>( view_query_fn, content_queries_fn, namespace_registry, + template_animations_feature, ); ComponentDefinitions { cmp_definition, fac_definition } @@ -125,6 +127,7 @@ fn generate_cmp_definition<'a>( view_query_fn: Option>, content_queries_fn: Option>, namespace_registry: &mut NamespaceRegistry<'a>, + template_animations_feature: bool, ) -> OutputExpression<'a> { let mut entries: OxcVec<'a, LiteralMapEntry<'a>> = OxcVec::new_in(&allocator); @@ -276,6 +279,7 @@ fn generate_cmp_definition<'a>( metadata, namespace_registry, options.angular_version, + template_animations_feature, ) { entries.push(LiteralMapEntry::new(Ident::from("features"), features, false)); } @@ -1005,6 +1009,7 @@ fn generate_features_array<'a>( metadata: &ComponentMetadata<'a>, namespace_registry: &mut NamespaceRegistry<'a>, angular_version: Option, + template_animations_feature: bool, ) -> Option> { let mut features: OxcVec<'a, OutputExpression<'a>> = OxcVec::new_in(&allocator); @@ -1056,16 +1061,19 @@ fn generate_features_array<'a>( features.push(external_styles_feature); } - // 6. AnimationsFeature - v20.2.0 to v20.3.0, for a host `animate.leave` + // 6. AnimationsFeature - v20.2.0 to v20.3.0, for a host `animate.leave`, + // or one in the template (`template_animations_feature`, which the caller + // sets only on those versions). Added once. // Format: ɵɵAnimationsFeature() if angular_version.is_some_and(|v| v.needs_animations_feature()) - && metadata.host.as_ref().is_some_and(|host| { - crate::directive::has_animate_leave_host_binding( - host.attributes.iter().map(|(key, _)| key.as_str()), - host.properties.iter().map(|(key, _)| key.as_str()), - host.listeners.iter().map(|(key, _)| key.as_str()), - ) - }) + && (template_animations_feature + || metadata.host.as_ref().is_some_and(|host| { + crate::directive::has_animate_leave_host_binding( + host.attributes.iter().map(|(key, _)| key.as_str()), + host.properties.iter().map(|(key, _)| key.as_str()), + host.listeners.iter().map(|(key, _)| key.as_str()), + ) + })) { let feature = create_angular_fn_ref(allocator, core_namespace, Identifiers::ANIMATIONS_FEATURE); @@ -1691,8 +1699,14 @@ mod tests { let metadata = create_test_metadata(&allocator); let mut namespace_registry = NamespaceRegistry::new(&allocator); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ); assert!(result.is_none(), "Should return None when no features are needed"); } @@ -1723,9 +1737,15 @@ mod tests { metadata.providers = Some(create_test_providers_array(&allocator, &["ServiceA", "ServiceB"])); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1743,9 +1763,15 @@ mod tests { metadata.providers = Some(create_test_providers_array(&allocator, &["ServiceA"])); metadata.view_providers = Some(create_test_providers_array(&allocator, &["ViewService"])); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1763,9 +1789,15 @@ mod tests { // Empty providers, but viewProviders present metadata.view_providers = Some(create_test_providers_array(&allocator, &["ViewService"])); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1782,9 +1814,15 @@ mod tests { let mut namespace_registry = NamespaceRegistry::new(&allocator); metadata.uses_inheritance = true; - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1801,9 +1839,15 @@ mod tests { let mut namespace_registry = NamespaceRegistry::new(&allocator); metadata.lifecycle = LifecycleMetadata { uses_on_changes: true }; - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1821,9 +1865,15 @@ mod tests { metadata.external_styles.push(Ident::from("./styles.css")); metadata.external_styles.push(Ident::from("./theme.css")); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1842,9 +1892,15 @@ mod tests { let directive = HostDirectiveMetadata::new(&allocator, Ident::from("MyDirective")); metadata.host_directives.push(directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1864,9 +1920,15 @@ mod tests { directive.outputs.push((Ident::from("publicOutput"), Ident::from("internalOutput"))); metadata.host_directives.push(directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1889,9 +1951,15 @@ mod tests { directive.is_forward_reference = true; metadata.host_directives.push(directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1913,9 +1981,15 @@ mod tests { .with_source_module(Ident::from("@angular/external")); metadata.host_directives.push(directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1947,9 +2021,15 @@ mod tests { .with_source_module(Ident::from("@angular/library")); metadata.host_directives.push(imported_directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -1978,9 +2058,15 @@ mod tests { metadata.uses_inheritance = true; metadata.lifecycle = LifecycleMetadata { uses_on_changes: true }; - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); @@ -2273,9 +2359,15 @@ mod tests { let directive = HostDirectiveMetadata::new(&allocator, Ident::from("HostDir")); metadata.host_directives.push(directive); - let result = - generate_features_array(&allocator, "i0", &metadata, &mut namespace_registry, None) - .unwrap(); + let result = generate_features_array( + &allocator, + "i0", + &metadata, + &mut namespace_registry, + None, + false, + ) + .unwrap(); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&result); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b9b2ba12a..1dcea08e6 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -528,6 +528,11 @@ pub struct HmrTemplateCompileOutput { /// content. It refers to a constant declared in `declarations_js`. pub ng_content_selectors_js: Option, + /// Whether the template needs `ɵɵAnimationsFeature()`: it uses + /// `animate.leave` and the version is v20.2.0 to v20.3.0 (see + /// [`crate::hmr::HmrTemplateFields::animations_feature`]). + pub animations_feature: bool, + /// 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. @@ -5690,6 +5695,8 @@ struct HmrUpdateParts { ng_content_selectors_js: Option, /// The ɵcmp's `styles`. styles: Vec, + /// Whether the template adds `ɵɵAnimationsFeature()` to the ɵcmp. + animations_feature: bool, } /// The `dependencies` entries of the directives and pipes of `entries`, @@ -6256,6 +6263,7 @@ pub fn compile_component_hmr_update( vars: parts.vars, consts_js: parts.consts_js.as_deref(), ng_content_selectors_js: parts.ng_content_selectors_js.as_deref(), + animations_feature: parts.animations_feature, }), styles: update.styles.as_deref(), declarations_js: Some(parts.declarations_js.as_str()).filter(|d| !d.is_empty()), @@ -7208,6 +7216,11 @@ fn compile_component_full<'a>( // `setClassMetadataAsync` gate both depend on whether the template uses // `@defer`. let has_defer_block = super::defer_resolver::template_has_defer_block(&r3_result.nodes); + // ngtsc's `addFeatures` adds `ɵɵAnimationsFeature()` on v20.2.0 to v20.3.0 + // when the template uses `animate.leave`, and so does an HMR update. + let template_animations_feature = + options.angular_version.is_some_and(|v| v.needs_animations_feature()) + && crate::directive::template_uses_animate_leave(&r3_result.nodes); // An HMR update module cannot carry the resolver: its dynamic `import()`s // would resolve against the update module's URL, not the component file. @@ -7422,6 +7435,7 @@ fn compile_component_full<'a>( view_query_fn, content_queries_fn, namespace_registry, + template_animations_feature, ); // Emit JavaScript @@ -7459,6 +7473,7 @@ fn compile_component_full<'a>( ) .map(|selectors| emitter.emit_expression(selectors)), styles: cmp_definition_styles(&definitions.cmp_definition, &job.pool), + animations_feature: template_animations_feature, }); // Emit the long styles this component shared (the pool's last constants) @@ -8186,6 +8201,9 @@ pub fn compile_template_for_hmr<'a>( return Err(diagnostics); } + let animations_feature = options.angular_version.is_some_and(|v| v.needs_animations_feature()) + && crate::directive::template_uses_animate_leave(&r3_result.nodes); + // Build IngestOptions from TransformOptions // OXC is a single-file compiler (local compilation mode): always use Full mode. let mode = TemplateCompilationMode::Full; @@ -8337,6 +8355,7 @@ pub fn compile_template_for_hmr<'a>( decls: job.root.decl_count.unwrap_or(0), vars: job.root.vars.unwrap_or(0), ng_content_selectors_js, + animations_feature, diagnostics, }) } @@ -8859,6 +8878,11 @@ pub struct LinkerTemplateOutput { /// The ngContentSelectors array as a JavaScript expression string, if any. pub ng_content_selectors_js: Option, + + /// Whether the template needs `ɵɵAnimationsFeature()`: it uses + /// `animate.leave` and the version is v20.2.0 to v20.3.0. Angular's linker + /// runs the same `addFeatures` as a full compile. + pub animations_feature: bool, } /// Compile a template for the linker, returning all data needed to build a `defineComponent` call. @@ -8938,6 +8962,9 @@ pub fn compile_template_for_linker<'a>( return Err(diagnostics); } + let animations_feature = angular_version.is_some_and(|v| v.needs_animations_feature()) + && crate::directive::template_uses_animate_leave(&r3_result.nodes); + // Like the TS linker's `createR3ComponentDeferMetadata`: the i-th entry of // `deferBlockDependencies` belongs to the i-th `@defer` block, and a block // past the end of the list (or a `null` entry) has no dependency function. @@ -9079,6 +9106,7 @@ pub fn compile_template_for_linker<'a>( vars, consts_js, ng_content_selectors_js, + animations_feature, }) } diff --git a/crates/oxc_angular_compiler/src/directive/metadata.rs b/crates/oxc_angular_compiler/src/directive/metadata.rs index 894fdfe69..f380f4fb2 100644 --- a/crates/oxc_angular_compiler/src/directive/metadata.rs +++ b/crates/oxc_angular_compiler/src/directive/metadata.rs @@ -319,6 +319,53 @@ pub fn has_animate_leave_host_binding<'k>( || listeners.into_iter().any(|key| strip_host_key(key, '(', ')') == ANIMATE_LEAVE) } +/// Whether an element of the template has an `animate.leave` attribute, input +/// or output, as Angular's `analyzeTemplateForAnimations` checks for +/// `ɵɵAnimationsFeature()` (v20.2.0 to v20.3.0). Like Angular it looks at +/// elements only, in every view (control flow blocks, `@defer`, +/// `` children), and not at an ``'s own attributes. +/// +/// The input names follow Angular's, which differ from Oxc's in two cases. A +/// `[class.x]` input is skipped: Angular names it by the part after `class.` +/// up to the next dot, so it is never `animate.leave`, while Oxc keeps the rest +/// of the name. A legacy `[@x]` input is named without its `@`, as in Angular. +pub fn template_uses_animate_leave(nodes: &[crate::ast::r3::R3Node<'_>]) -> bool { + use crate::ast::expression::BindingType; + use crate::ast::r3::{R3Element, R3Visitor, visit_all}; + + const ANIMATE_LEAVE: &str = "animate.leave"; + + struct Finder { + found: bool, + } + + impl<'a> R3Visitor<'a> for Finder { + fn visit_element(&mut self, element: &R3Element<'a>) { + if element.attributes.iter().any(|attr| attr.name == ANIMATE_LEAVE) + || element.inputs.iter().any(|input| { + let name = input.name.as_str(); + match input.binding_type { + BindingType::Class => false, + BindingType::LegacyAnimation => { + name.strip_prefix('@').unwrap_or(name) == ANIMATE_LEAVE + } + _ => name == ANIMATE_LEAVE, + } + }) + || element.outputs.iter().any(|output| output.name == ANIMATE_LEAVE) + { + self.found = true; + return; + } + self.visit_element_children(element); + } + } + + let mut finder = Finder { found: false }; + visit_all(&mut finder, nodes); + finder.found +} + /// `key` without the brackets of a `[property]` / `(event)` host key: the key /// ngtsc's `parseHostBindings` stores the binding under. pub fn strip_host_key(key: &str, open: char, close: char) -> &str { diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 4ead4cf8a..c4eb6484a 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -48,6 +48,7 @@ pub use evaluator::input_transform_types; pub(crate) use evaluator::{Evaluator, ImportValueResolver, StaticValue}; pub(crate) use metadata::{ MemberHostBindings, has_animate_leave_host_binding, merge_host_bindings, strip_host_key, + template_uses_animate_leave, }; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index d19d9098c..9eb340e3a 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -26,6 +26,13 @@ pub struct HmrTemplateFields<'a> { /// The `ngContentSelectors` array as JavaScript code, if the template projects /// content. pub ng_content_selectors_js: Option<&'a str>, + + /// Whether the new template needs `ɵɵAnimationsFeature()` (an `animate.leave` + /// on v20.2.0 to v20.3.0). ngtsc's update module runs `ɵɵdefineComponent` + /// again, which runs the feature and sets up the registry `ɵɵanimateLeave` + /// needs. The live definition is copied here instead, so the module runs + /// the feature itself. + pub animations_feature: bool, } /// Options for generating HMR update modules. @@ -115,6 +122,7 @@ pub fn generate_hmr_update_module(options: &HmrUpdateModuleOptions<'_>) -> Strin // matches the template it was built for, so keep it and let // recreateMatchingLViews re-render rather than rebuilding it. options.template.is_some(), + options.template.is_some_and(|template| template.animations_feature), ) } @@ -169,13 +177,16 @@ pub fn generate_hmr_update_module_from_js( styles, declarations_js, !template_js.is_empty(), + false, ) } /// 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. +/// must only be set when `fields` replaces the template. `animations_feature` +/// runs `ɵɵAnimationsFeature()` on the new definition (see +/// [`HmrTemplateFields::animations_feature`]). fn emit_update_module( component_id: &str, class_name: &str, @@ -183,6 +194,7 @@ fn emit_update_module( styles: Option<&[String]>, declarations_js: Option<&str>, clear_tview: bool, + animations_feature: bool, ) -> String { let mut output = String::new(); @@ -241,6 +253,9 @@ fn emit_update_module( output.push_str(" tView: null,\n"); } output.push_str(" };\n"); + if animations_feature { + output.push_str(&format!(" i0.ɵɵAnimationsFeature()({class_name}.ɵcmp);\n")); + } output.push_str("}\n"); output @@ -256,6 +271,7 @@ mod tests { vars: 2, consts_js: None, ng_content_selectors_js: None, + animations_feature: false, }; fn module( diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 4d40adbcc..2d1816066 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1721,7 +1721,7 @@ fn link_directive<'a>( parts.push("signals: true".to_string()); } - if let Some(features) = build_features(meta, source, ns, options.angular_version) { + if let Some(features) = build_features(meta, source, ns, options.angular_version, false) { parts.push(format!("features: {features}")); } @@ -2158,6 +2158,7 @@ fn build_features( source: &str, ns: &str, angular_version: Option, + template_animations_feature: bool, ) -> Option { let mut features: Vec = Vec::new(); @@ -2204,8 +2205,10 @@ fn build_features( } // 6. AnimationsFeature — v20.2.0 to v20.3.0, for a host `animate.leave` + // or one in a component's template (`template_animations_feature`) if angular_version.is_some_and(|v| v.needs_animations_feature()) - && get_object_property(meta, "host").is_some_and(has_animate_leave_host) + && (template_animations_feature + || get_object_property(meta, "host").is_some_and(has_animate_leave_host)) { features.push(format!("{ns}.\u{0275}\u{0275}AnimationsFeature()")); } @@ -2439,7 +2442,13 @@ fn link_component<'a>( } // 12. features - if let Some(features) = build_features(meta, source, ns, options.angular_version) { + if let Some(features) = build_features( + meta, + source, + ns, + options.angular_version, + template_output.animations_feature, + ) { parts.push(format!("features: {features}")); } diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 79aba6fe2..c6d1d3829 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -1432,6 +1432,8 @@ fn ingest_static_attributes_with_i18n<'a>( use crate::output::ast::{LiteralExpr, LiteralValue, OutputExpression}; let allocator = job.allocator; + // Before v20.2 a static `animate.*` is a plain attribute. + let animate = job.angular_version.is_none_or(|v| v.supports_animate_instructions()); for attr in attributes { let name = attr.name.clone(); @@ -1444,7 +1446,7 @@ fn ingest_static_attributes_with_i18n<'a>( ); // ngNonBindable and animate.* require special handling - if name.as_str() == "ngNonBindable" || name.as_str().starts_with("animate.") { + if name.as_str() == "ngNonBindable" || (animate && name.as_str().starts_with("animate.")) { let literal_expr = OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(value), source_span: None }, &allocator, @@ -4592,7 +4594,11 @@ fn ingest_host_event<'a>(job: &mut HostBindingCompilationJob<'a>, event: R3Bound // A LegacyAnimation event with no phase (e.g. `@HostListener('@anim')`) leaves // is_animation_listener=false so reify emits a plain ɵɵlistener — matching // Angular's `isLegacyAnimationListener=false` path. - if event.event_type == ParsedEventType::Animation { + // + // Before v20.2 an `animate.*` host event is a plain listener. + if event.event_type == ParsedEventType::Animation + && job.angular_version.is_none_or(|v| v.supports_animate_instructions()) + { let root_xref = job.root.xref; let op = create_animation_listener_op( allocator, @@ -4733,11 +4739,13 @@ fn ingest_control_flow_insertion_point<'a, 'b>( let allocator = job.allocator; // Collect static attributes for content projection purposes. + // Before v20.2 `animate.*` attributes are plain ones and are copied too. + let animate = job.angular_version.is_none_or(|v| v.supports_animate_instructions()); let attributes = root.attributes(); for attr in attributes { let attr_name = attr.name.as_str(); // Skip animation attributes - if attr_name.starts_with(ANIMATE_PREFIX) { + if animate && attr_name.starts_with(ANIMATE_PREFIX) { continue; } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs index 8a5d8ac08..3ef3d064d 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs @@ -88,6 +88,8 @@ fn split_ns_name(name: &str) -> (Option<&str>, &str) { pub fn specialize_bindings(job: &mut ComponentCompilationJob<'_>) { let allocator = job.allocator; let mode = job.mode; + // Before v20.2 an `animate.*` attribute is a plain attribute. + let animate = job.angular_version.is_none_or(|v| v.supports_animate_instructions()); // First pass: Build element map from create operations let mut elements: FxHashMap = FxHashMap::default(); @@ -112,12 +114,14 @@ pub fn specialize_bindings(job: &mut ComponentCompilationJob<'_>) { let mut all_non_bindable: Vec = Vec::new(); // Process root view - let root_non_bindable = specialize_in_view(&mut job.root.update, &allocator, &elements, mode); + let root_non_bindable = + specialize_in_view(&mut job.root.update, &allocator, &elements, mode, animate); all_non_bindable.extend(root_non_bindable); // Process embedded views for view in job.views.values_mut() { - let view_non_bindable = specialize_in_view(&mut view.update, &allocator, &elements, mode); + let view_non_bindable = + specialize_in_view(&mut view.update, &allocator, &elements, mode, animate); all_non_bindable.extend(view_non_bindable); } @@ -185,6 +189,7 @@ fn specialize_in_view<'a>( allocator: &'a oxc_allocator::Allocator, _elements: &FxHashMap, mode: TemplateCompilationMode, + animate: bool, ) -> Vec { // Track ops to remove (ngNonBindable) let mut to_remove: Vec>> = Vec::new(); @@ -210,7 +215,7 @@ fn specialize_in_view<'a>( to_remove.push(ptr); } mark_non_bindable.push(target); - } else if name.as_str().starts_with("animate.") { + } else if animate && name.as_str().starts_with("animate.") { // Convert to animation binding if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() { let expression = std::mem::replace( diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs b/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs index 523bc62d1..20c0f46c1 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs @@ -11,7 +11,8 @@ //! 5. Removes the original AnimationBinding from the update list //! //! Both Animation and AnimationString are CreateOps that are inserted after -//! their target element in the create list, matching Angular's architecture. +//! their target element in the create list, one at a time in update-list +//! order, matching Angular's architecture. //! For host bindings they are pushed to the end of the create list instead. //! //! Ported from Angular's `template/pipeline/src/phases/convert_animations.ts`. @@ -26,7 +27,7 @@ use crate::ir::enums::{AnimationBindingKind, AnimationKind}; use crate::ir::expression::IrExpression; use crate::ir::ops::{ AnimationBindingOp, AnimationOp, AnimationStringOp, CreateOp, CreateOpBase, StatementOp, - UpdateOp, UpdateOpBase, XrefId, + UpdateOp, UpdateOpBase, }; use crate::output::ast::{OutputExpression, OutputStatement, ReturnStatement, WrappedIrExpr}; use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob}; @@ -37,24 +38,6 @@ fn get_animation_kind(name: &str) -> AnimationKind { if name == "animate.enter" { AnimationKind::Enter } else { AnimationKind::Leave } } -/// Info needed to create Animation CreateOps. -struct AnimationInfo<'a> { - target: XrefId, - name: Ident<'a>, - animation_kind: AnimationKind, - handler_ops: OxcVec<'a, UpdateOp<'a>>, - source_span: Option, -} - -/// Info needed to create AnimationString CreateOps. -struct AnimationStringInfo<'a> { - target: XrefId, - name: Ident<'a>, - animation_kind: AnimationKind, - expression: Box<'a, IrExpression<'a>>, - source_span: Option, -} - /// Converts animation bindings to animation runtime calls. /// /// This phase transforms `AnimationBinding` update operations into either @@ -62,7 +45,11 @@ struct AnimationStringInfo<'a> { /// - `AnimationBindingKind::Value` -> `AnimationOp` (CreateOp) /// - `AnimationBindingKind::String` -> `AnimationStringOp` (CreateOp) /// -/// Both are inserted into the create list after their target element. +/// The bindings are taken in update-list order, and each new op goes right +/// after its target element, as Angular's `convertAnimations` does +/// (`OpList.insertAfter`). So the last binding of an element comes first: for +/// `

` that is `ɵɵanimateEnter(cb)` +/// then `ɵɵanimateLeave("out")`. pub fn convert_animations(job: &mut ComponentCompilationJob<'_>) { let allocator = job.allocator; @@ -70,189 +57,57 @@ pub fn convert_animations(job: &mut ComponentCompilationJob<'_>) { let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect(); for view_xref in view_xrefs { - // First pass: collect all AnimationBindingOp pointers - // We need to collect pointers first because we can't iterate and modify at the same time - let binding_ptrs: Vec>> = { - let view = match job.view_mut(view_xref) { - Some(v) => v, - None => continue, - }; - - let mut ptrs = Vec::new(); - for op in view.update.iter() { - if matches!(op, UpdateOp::AnimationBinding(_)) { - ptrs.push(std::ptr::NonNull::from(op)); - } - } - ptrs - }; - - // Second pass: process each AnimationBindingOp - let mut animations_to_create: Vec> = Vec::new(); - let mut strings_to_create: Vec> = Vec::new(); - - for ptr in binding_ptrs { - let view = match job.view_mut(view_xref) { - Some(v) => v, - None => continue, - }; - - // SAFETY: ptr was obtained from a valid reference in the list - let op = unsafe { ptr.as_ref() }; - if let UpdateOp::AnimationBinding(binding) = op { - let target = binding.target; - let source_span = binding.base.source_span; - let name = binding.name.clone(); - let kind = binding.kind; - let animation_kind = get_animation_kind(name.as_str()); - - // Extract expression by replacing with placeholder - // SAFETY: ptr was obtained from a valid reference in the list - let expression = unsafe { - let op_mut = &mut *(ptr.as_ptr() as *mut UpdateOp<'_>); - if let UpdateOp::AnimationBinding(binding_mut) = op_mut { - std::mem::replace( - &mut binding_mut.expression, - create_placeholder_expression(allocator), - ) - } else { - continue; - } - }; - - // Remove from update list - both kinds become CreateOps - // SAFETY: ptr was obtained from this list - unsafe { view.update.remove(ptr) }; - - match kind { - AnimationBindingKind::String => { - strings_to_create.push(AnimationStringInfo { - target, - name, - animation_kind, - expression, - source_span, - }); - } - AnimationBindingKind::Value => { - // Create handler_ops with a return statement containing the expression - // This matches Angular's createAnimationOp which wraps expression in ReturnStatement - let mut handler_ops = OxcVec::new_in(&allocator); - - // Create a WrappedIrNode to hold the IR expression - let wrapped_expr = OutputExpression::WrappedIrNode(Box::new_in( - WrappedIrExpr { node: expression, source_span }, - &allocator, - )); + let mut missing_targets: Vec> = Vec::new(); - let return_stmt = OutputStatement::Return(Box::new_in( - ReturnStatement { value: wrapped_expr, source_span: None }, - &allocator, - )); - - handler_ops.push(UpdateOp::Statement(StatementOp { - base: UpdateOpBase { source_span, ..Default::default() }, - statement: return_stmt, - })); - - animations_to_create.push(AnimationInfo { - target, - name, - animation_kind, - handler_ops, - source_span, - }); - } + if let Some(view) = job.view_mut(view_xref) { + // Take every AnimationBinding out of the update list, in order. + let mut created = Vec::new(); + let mut cursor = view.update.cursor(); + cursor.move_next(); + while let Some(op) = cursor.current_mut() { + if let UpdateOp::AnimationBinding(binding) = op { + let target = binding.target; + let name = binding.name; + created.push((target, name, animation_create_op(allocator, binding))); + cursor.remove_current(); + } else { + cursor.move_next(); } } - } - - // Third pass: insert Animation CreateOps into create list after their target elements - if !animations_to_create.is_empty() || !strings_to_create.is_empty() { - let mut missing_targets: Vec> = Vec::new(); - if let Some(view) = job.view_mut(view_xref) { - // Process Animation ops (Value kind) - for info in animations_to_create { - let mut create_op = Some(CreateOp::Animation(AnimationOp { - base: CreateOpBase { source_span: info.source_span, ..Default::default() }, - target: info.target, - name: info.name.clone(), - animation_kind: info.animation_kind, - handler_ops: info.handler_ops, - handler_fn_name: None, - i18n_message: None, - security_context: SecurityContext::None, - sanitizer: None, - })); - - // Find target element in create list and insert after it - let mut cursor = view.create.cursor(); - while cursor.move_next() { - let is_target = match cursor.current() { - Some(CreateOp::ElementStart(el)) => el.xref == info.target, - Some(CreateOp::Element(el)) => el.xref == info.target, - Some(CreateOp::Template(t)) => t.xref == info.target, - Some(CreateOp::ContainerStart(c)) => c.xref == info.target, - Some(CreateOp::Container(c)) => c.xref == info.target, - _ => false, - }; - if is_target { - if let Some(op) = create_op.take() { - cursor.insert_after(op); - } - break; + // Insert each one right after its target element in the create list. + for (target, name, op) in created { + let mut create_op = Some(op); + let mut cursor = view.create.cursor(); + while cursor.move_next() { + let is_target = match cursor.current() { + Some(CreateOp::ElementStart(el)) => el.xref == target, + Some(CreateOp::Element(el)) => el.xref == target, + Some(CreateOp::Template(t)) => t.xref == target, + Some(CreateOp::ContainerStart(c)) => c.xref == target, + Some(CreateOp::Container(c)) => c.xref == target, + _ => false, + }; + if is_target { + if let Some(op) = create_op.take() { + cursor.insert_after(op); } - } - - if let Some(op) = create_op { - missing_targets.push(info.name); - view.create.push(op); + break; } } - // Process AnimationString ops (String kind) - for info in strings_to_create { - let mut create_op = Some(CreateOp::AnimationString(AnimationStringOp { - base: CreateOpBase { source_span: info.source_span, ..Default::default() }, - target: info.target, - name: info.name.clone(), - animation_kind: info.animation_kind, - expression: info.expression, - })); - - // Find target element in create list and insert after it - let mut cursor = view.create.cursor(); - while cursor.move_next() { - let is_target = match cursor.current() { - Some(CreateOp::ElementStart(el)) => el.xref == info.target, - Some(CreateOp::Element(el)) => el.xref == info.target, - Some(CreateOp::Template(t)) => t.xref == info.target, - Some(CreateOp::ContainerStart(c)) => c.xref == info.target, - Some(CreateOp::Container(c)) => c.xref == info.target, - _ => false, - }; - if is_target { - if let Some(op) = create_op.take() { - cursor.insert_after(op); - } - break; - } - } - - if let Some(op) = create_op { - missing_targets.push(info.name); - view.create.push(op); - } + if let Some(op) = create_op { + missing_targets.push(name); + view.create.push(op); } } + } - // Add diagnostics for missing targets after releasing the view borrow - for name in missing_targets { - job.diagnostics.push(OxcDiagnostic::warn(format!( - "Animation target element not found for animation '{name}'" - ))); - } + // Add diagnostics for missing targets after releasing the view borrow + for name in missing_targets { + job.diagnostics.push(OxcDiagnostic::warn(format!( + "Animation target element not found for animation '{name}'" + ))); } } } diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 8b09a8231..69a106bc9 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -338,6 +338,14 @@ impl<'a> HtmlToR3Transform<'a> { })) } + /// Whether `animate.*` names are animation bindings (v20.2.0+, or no version + /// given). Before v20.2 Angular had no such bindings: `[animate.x]`, + /// `(animate.x)` and a static `animate.x` are plain properties, events and + /// attributes. + fn supports_animate(&self) -> bool { + self.angular_version.is_none_or(|v| v.supports_animate_instructions()) + } + fn security_context(&self, element: &str, property: &str) -> SecurityContext { // Element bindings go through `calcPossibleSecurityContexts` upstream, // which promotes a bare element missing from the DOM schema to its @@ -3482,11 +3490,12 @@ impl<'a> HtmlToR3Transform<'a> { } BindingPrefix::BindOn => { // Two-way binding creates both input and output + let binding_type = self.two_way_binding_type(rest); inputs.push(self.create_bound_attribute( element_name, rest, attr, - BindingType::TwoWay, + binding_type, None, property_binding_key_span(rest, key_span), )); @@ -3528,11 +3537,12 @@ impl<'a> HtmlToR3Transform<'a> { // delims.start, identifier)`) let key_span = create_key_span(attr, BANANA_BOX_START, prop_name); // Two-way binding creates both input and output + let binding_type = self.two_way_binding_type(prop_name); inputs.push(self.create_bound_attribute( element_name, prop_name, attr, - BindingType::TwoWay, + binding_type, None, property_binding_key_span(prop_name, key_span), )); @@ -3564,7 +3574,7 @@ impl<'a> HtmlToR3Transform<'a> { } else { (BindingType::Style, stripped, None) } - } else if prop_name.starts_with("animate.") { + } else if prop_name.starts_with("animate.") && self.supports_animate() { // [animate.xxx] keeps the full name "animate.xxx" (BindingType::Animation, prop_name, None) } else if prop_name.starts_with('@') { @@ -3811,7 +3821,7 @@ impl<'a> HtmlToR3Transform<'a> { let handler = self.parse_event_expression(&attr.value, handler_span); // Check for animation events (animate.xxx) - let event_type = if name.starts_with("animate.") { + let event_type = if name.starts_with("animate.") && self.supports_animate() { ParsedEventType::Animation } else { ParsedEventType::Regular @@ -3851,7 +3861,7 @@ impl<'a> HtmlToR3Transform<'a> { let handler = self.parse_event_expression(&attr.value, handler_span); let name_atom = Ident::from(self.allocator.alloc_str(name)); // `on-animate.enter` is an animation event, like `(animate.enter)` - let event_type = if name.starts_with("animate.") { + let event_type = if name.starts_with("animate.") && self.supports_animate() { ParsedEventType::Animation } else { ParsedEventType::Regular @@ -3914,6 +3924,17 @@ impl<'a> HtmlToR3Transform<'a> { } } + /// The input type of a two-way binding `[(prop)]` / `bindon-prop`. From v20.2 + /// `[(animate.x)]` is an animation binding: Angular's `parsePropertyBinding` + /// checks the `animate.` prefix before it looks at two-way. + fn two_way_binding_type(&self, prop_name: &str) -> BindingType { + if prop_name.starts_with("animate.") && self.supports_animate() { + BindingType::Animation + } else { + BindingType::TwoWay + } + } + /// Creates a bound event for two-way binding. /// The handler is just the target expression (e.g., `name`), not the full assignment. /// The `$event` is added separately in the ingest phase. @@ -3926,6 +3947,23 @@ impl<'a> HtmlToR3Transform<'a> { let handler_span = self.calculate_event_handler_span(attr); let name_atom = Ident::from(self.allocator.alloc_str(name)); + // From v20.2 `animate.xChange` is an animation event, as Angular's + // `_parseRegularEvent` gives it that type over two-way. Its handler is + // the action itself (`return ctx.x`), with no `$event` assignment. + if name.starts_with("animate.") && self.supports_animate() { + let handler = self.parse_event_expression(&attr.value, handler_span); + return R3BoundEvent { + name: name_atom, + event_type: ParsedEventType::Animation, + handler, + target: None, + phase: None, + source_span: attr.span, + handler_span, + key_span, + }; + } + // For two-way binding, the handler is just the target expression (e.g., `name`) // NOT the full assignment `name = $event`. // The $event reference and TwoWayBindingSetExpr are created in the ingest phase. @@ -4539,13 +4577,15 @@ impl<'a> HtmlToR3Transform<'a> { /// Filters out animation attributes (those starting with "animate."). /// These should remain on the inner element, not be hoisted to the template. + /// Before v20.2 they are plain attributes and are hoisted like the others. fn filter_animation_attributes( &self, attributes: &Vec<'a, R3TextAttribute<'a>>, ) -> Vec<'a, R3TextAttribute<'a>> { + let animate = self.supports_animate(); let mut result = Vec::new_in(&self.allocator); for attr in attributes { - if !attr.name.as_str().starts_with("animate.") { + if !(animate && attr.name.as_str().starts_with("animate.")) { result.push(R3TextAttribute { name: attr.name, value: attr.value, diff --git a/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs b/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs index 0afd0e6c3..56e818ac6 100644 --- a/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs +++ b/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs @@ -303,3 +303,52 @@ fn leaves_deferred_imports_to_the_live_definition() { assert!(!module.contains("import("), "{module}"); assert!(module.contains("ɵɵdefer("), "{module}"); } + +/// #701: on 20.2.0 to 20.3.0 an `animate.leave` template needs +/// `ɵɵAnimationsFeature()`. ngtsc's update module runs `ɵɵdefineComponent` again, +/// which runs it; this one copies the live definition, so it runs the feature +/// on the new one. +#[test] +fn runs_the_animations_feature_for_animate_leave() { + use oxc_angular_compiler::AngularVersion; + + let source = component("", r#"

"#); + let call = "i0.ɵɵAnimationsFeature()(X.ɵcmp);\n}"; + let cases = [ + (Some(AngularVersion::new(20, 2, 0)), true), + (Some(AngularVersion::new(20, 3, 0)), true), + (Some(AngularVersion::new(20, 1, 8)), false), + (Some(AngularVersion::new(20, 3, 1)), false), + (None, false), + ]; + for (version, feature) in cases { + let options = hmr(TransformOptions { angular_version: version, ..Default::default() }); + let module = update(&source, &options, None).module.unwrap(); + assert_eq!(module.contains(call), feature, "{version:?}\n{module}"); + assert_eq!( + transform(&source, &options, None).contains("ɵɵAnimationsFeature()"), + feature, + "{version:?}" + ); + + let allocator = Allocator::default(); + let old = compile_template_for_hmr( + &allocator, + r#"

"#, + "X", + PATH, + &options, + ) + .unwrap(); + assert_eq!(old.animations_feature, feature, "{version:?}"); + } + + // `animate.enter` alone needs no feature. + let source = component("", r#"

"#); + let options = hmr(TransformOptions { + angular_version: Some(AngularVersion::new(20, 2, 0)), + ..Default::default() + }); + let module = update(&source, &options, None).module.unwrap(); + assert!(!module.contains("AnimationsFeature"), "{module}"); +} diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 2ea704696..85cfef58b 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -19400,3 +19400,305 @@ export class LaterDirective {} result.code ); } + +// ============================================================================ +// #701: template `animate.*`: version gate, AnimationsFeature, order +// ============================================================================ + +/// A standalone component `T_{name}` with `template`, as the ngtsc reference +/// build compiles it. +fn animate_component(name: &str, template: &str) -> String { + format!( + "import {{ Component, NO_ERRORS_SCHEMA }} from '@angular/core';\n\ + @Component({{ selector: 't-{name}', schemas: [NO_ERRORS_SCHEMA], template: {template:?} }})\n\ + export class T_{name} {{ e = 'in'; c = 1; l = [1]; g(_: unknown) {{}} }}\n" + ) +} + +fn compile_for_version(source: &str, version: Option) -> String { + let options = ComponentTransformOptions { + angular_version: version, + ..ComponentTransformOptions::default() + }; + compile_compact(source, Some(&options)) +} + +const ISSUE_701_TEMPLATE: &str = + r#"

"#; + +/// #701: Angular before 20.2 has no `animate.*` bindings: a static `animate.*` +/// is a plain attribute, `[animate.*]` a property and `(animate.*)` a plain +/// listener. Expected strings are from ngtsc 20.1.8 and 19.2.15. +#[test] +fn test_template_animate_before_20_2() { + let source = animate_component("issue", ISSUE_701_TEMPLATE); + + let code = compile_for_version(&source, Some(AngularVersion::new(20, 1, 8))); + assert!( + code.contains(r#"consts:[["animate.leave","out",3,"animate.leave","animate.enter"]]"#), + "{code}" + ); + assert!( + code.contains(r#"i0.ɵɵdomElementStart(0,"p",0);i0.ɵɵdomListener("animate.leave",functionT_issue_Template_p_animate_leave_0_listener($event){returnctx.g($event);});i0.ɵɵdomElementEnd();"#), + "{code}" + ); + assert!(code.contains(r#"i0.ɵɵdomProperty("animate.enter",ctx.e);"#), "{code}"); + assert!(!code.contains("ɵɵanimate"), "{code}"); + + let code = compile_for_version(&source, Some(AngularVersion::new(19, 2, 15))); + assert!( + code.contains(r#"consts:[["animate.leave","out",3,"animate.leave","animate.enter"]]"#), + "{code}" + ); + assert!( + code.contains(r#"i0.ɵɵelementStart(0,"p",0);i0.ɵɵlistener("animate.leave",functionT_issue_Template_p_animate_leave_0_listener($event){returnctx.g($event);});i0.ɵɵelementEnd();"#), + "{code}" + ); + assert!(code.contains(r#"i0.ɵɵproperty("animate.enter",ctx.e);"#), "{code}"); + assert!(!code.contains("ɵɵanimate"), "{code}"); + + // More shapes, from ngtsc 20.1.8. + let v20_1 = Some(AngularVersion::new(20, 1, 8)); + let cases: [(&str, &str, &[&str]); 5] = [ + ( + "attrleave", + r#"

"#, + &[r#"i0.ɵɵdomElement(0,"p");"#, r#"i0.ɵɵattribute("animate.leave",ctx.e);"#], + ), + ( + "ifblock", + r#"@if (c) {

}"#, + &[ + r#"consts:[["animate.leave","out"]]"#, + r#"i0.ɵɵconditionalCreate(0,T_ifblock_Conditional_0_Template,1,0,"p",0);"#, + ], + ), + ( + "structural", + r#"

"#, + &[ + r#"consts:[["animate.leave","out",4,"foo"],["animate.leave","out"]]"#, + r#"i0.ɵɵdomTemplate(0,T_structural_p_0_Template,1,0,"p",0);"#, + ], + ), + ( + "container", + r#""#, + &[r#"consts:[["animate.leave","x"]]"#, "i0.ɵɵdomElementContainer(0,0);"], + ), + ( + "twoway", + r#"

"#, + &[ + r#"consts:[[3,"animate.leaveChange","animate.leave"]]"#, + r#"i0.ɵɵtwoWayListener("animate.leaveChange",functionT_twoway_Template_p_animate_leaveChange_0_listener($event){(i0.ɵɵtwoWayBindingSet(ctx.e,$event)||(ctx.e=$event));return$event;});"#, + r#"i0.ɵɵtwoWayProperty("animate.leave",ctx.e);"#, + ], + ), + ]; + for (name, template, parts) in cases { + let code = compile_for_version(&animate_component(name, template), v20_1); + for part in parts { + assert!(code.contains(part), "{name}: missing {part}\n{code}"); + } + assert!(!code.contains("ɵɵanimate"), "{name}: {code}"); + } +} + +/// #701: before 20.2 a host `(animate.*)` listener is a plain `ɵɵlistener`, and +/// it chains with the other listeners. Expected strings are from ngtsc 20.1.8. +#[test] +fn test_host_animate_listener_before_20_2() { + let source = r" +import { Directive } from '@angular/core'; +@Directive({ selector: '[d]', host: { '(animate.leave)': 'g($event)', '[attr.animate.enter]': 'e', '(click)': 'g(1)' } }) +export class D { e = 'in'; g(_: unknown) {} } +"; + for version in [AngularVersion::new(20, 1, 8), AngularVersion::new(19, 2, 15)] { + let code = compile_for_version(source, Some(version)); + assert!( + code.contains(r#"i0.ɵɵlistener("animate.leave",functionD_animate_leave_HostBindingHandler($event){returnctx.g($event);})("click",functionD_click_HostBindingHandler(){returnctx.g(1);});"#), + "{version:?} {code}" + ); + assert!(code.contains(r#"i0.ɵɵattribute("animate.enter",ctx.e);"#), "{code}"); + assert!(!code.contains("ɵɵanimate"), "{version:?} {code}"); + } + + let code = compile_for_version(source, Some(AngularVersion::new(20, 2, 0))); + assert!( + code.contains( + "i0.ɵɵanimateLeaveListener(functionD_animateleave_HostBindingHandler($event){returnctx.g($event);});" + ), + "{code}" + ); + assert!(code.contains(r#"i0.ɵɵanimateEnter(ctx.e);"#), "{code}"); + + let component = r" +import { Component } from '@angular/core'; +@Component({ selector: 'hc-hlis', template: '', host: { '(animate.enter)': 'g($event)' } }) +export class HC_hlis { g(_: unknown) {} } +"; + let code = compile_for_version(component, Some(AngularVersion::new(20, 1, 8))); + assert!( + code.contains(r#"hostBindings:functionHC_hlis_HostBindings(rf,ctx){if((rf&1)){i0.ɵɵlistener("animate.enter",functionHC_hlis_animate_enter_HostBindingHandler($event){returnctx.g($event);});}}"#), + "{code}" + ); + assert!(!code.contains("ɵɵanimate"), "{code}"); + + let decorators = r" +import { Directive, HostListener, HostBinding } from '@angular/core'; +@Directive({ selector: '[hl]' }) +export class HL { @HostListener('animate.leave', ['$event']) g(_: unknown) {} @HostListener('document:animate.enter') h() {} @HostBinding('attr.animate.enter') e = 'in'; } +"; + let code = compile_for_version(decorators, Some(AngularVersion::new(20, 1, 8))); + assert!( + code.contains(r#"i0.ɵɵlistener("animate.leave",functionHL_animate_leave_HostBindingHandler($event){returnctx.g($event);})("animate.enter",functionHL_animate_enter_HostBindingHandler(){returnctx.h();},i0.ɵɵresolveDocument);"#), + "{code}" + ); + assert!(code.contains(r#"i0.ɵɵattribute("animate.enter",ctx.e);"#), "{code}"); + assert!(!code.contains("ɵɵanimate"), "{code}"); +} + +/// #701: Angular 20.2.0 to 20.3.0 adds `ɵɵAnimationsFeature()` when the +/// template uses `animate.leave` on an element (`analyzeTemplateForAnimations`). +#[test] +fn test_template_animate_leave_feature() { + let with_feature = [ + r#"

"#, + r#"

"#, + r#"

"#, + r#"

"#, + r#"

"#, + r#"

"#, + r#"@if (c) {

}"#, + r#"@defer {

} @placeholder { }"#, + r#"@for (i of l; track i) { } @empty {

}"#, + r#"@switch (c) { @case (1) {

} }"#, + r#"
"#, + r#"

"#, + r#""#, + ]; + let cases = [ + (Some(AngularVersion::new(20, 2, 0)), true), + (Some(AngularVersion::new(20, 3, 0)), true), + (Some(AngularVersion::new(20, 1, 8)), false), + (Some(AngularVersion::new(20, 3, 1)), false), + (Some(AngularVersion::new(22, 2, 4)), false), + (None, false), + ]; + for template in with_feature { + let source = animate_component("f", template); + for (version, feature) in cases { + let code = compile_for_version(&source, version); + assert_eq!( + code.contains("features:[i0.ɵɵAnimationsFeature()]"), + feature, + "{template} {version:?}\n{code}" + ); + } + } + + // Angular checks elements only, and names `[class.animate.leave]` `animate`. + let without_feature = [ + r#"

"#, + r#""#, + r#""#, + r#"

"#, + ]; + for template in without_feature { + let code = compile_for_version( + &animate_component("f", template), + Some(AngularVersion::new(20, 2, 0)), + ); + assert!(!code.contains("AnimationsFeature"), "{template}\n{code}"); + } + + // The ngtsc 20.2.0 output of the issue's template. + let code = compile_for_version( + &animate_component("issue", ISSUE_701_TEMPLATE), + Some(AngularVersion::new(20, 2, 0)), + ); + assert!( + code.contains(r#"ɵɵdefineComponent({type:T_issue,selectors:[["t-issue"]],features:[i0.ɵɵAnimationsFeature()],decls:1,vars:0,template:functionT_issue_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p");i0.ɵɵanimateEnter(functionT_issue_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave("out");i0.ɵɵanimateLeaveListener(functionT_issue_Template_p_animateleave_0_listener($event){returnctx.g($event);});i0.ɵɵdomElementEnd();}},encapsulation:2"#), + "{code}" + ); + + // A host and a template `animate.leave` give one feature. + let source = r#" +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +@Component({ selector: 'c', schemas: [NO_ERRORS_SCHEMA], template: '

', host: { 'animate.leave': 'x' } }) +export class C {} +"#; + let code = compile_for_version(source, Some(AngularVersion::new(20, 2, 0))); + assert_eq!(code.matches("ɵɵAnimationsFeature()").count(), 1, "{code}"); +} + +/// #701: each animate op goes right after its element in update-list order, so +/// the last binding comes first, as ngtsc 22.2.4 (and 20.2.0) emit it. +#[test] +fn test_template_animate_order_matches_ngtsc() { + let cases = [ + ( + "issue", + ISSUE_701_TEMPLATE, + r#"i0.ɵɵdomElementStart(0,"p");i0.ɵɵanimateEnter(functionT_issue_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave("out");i0.ɵɵanimateLeaveListener(functionT_issue_Template_p_animateleave_0_listener($event){returnctx.g($event);});i0.ɵɵdomElementEnd();"#, + ), + ( + "order2", + r#"

"#, + r#"i0.ɵɵanimateEnter(functionT_order2_Template_animateenter_cb(){returnctx.e;})("b");i0.ɵɵanimateLeave("a");"#, + ), + ( + "order4", + r#"

"#, + r#"i0.ɵɵanimateEnter(functionT_order4_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave(ctx.e);"#, + ), + ( + "order5", + r#"

"#, + r#"i0.ɵɵanimateEnter(functionT_order5_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave(ctx.e);i0.ɵɵanimateEnter("a");"#, + ), + ( + "order6", + r#"
"#, + r#"i0.ɵɵdomElementStart(0,"div");i0.ɵɵanimateEnter(functionT_order6_Template_animateenter_cb(){returnctx.e;});i0.ɵɵdomElementStart(1,"span");i0.ɵɵanimateLeave(functionT_order6_Template_animateleave_cb(){returnctx.e;})("x");i0.ɵɵdomElementEnd()();"#, + ), + ]; + for version in [None, Some(AngularVersion::new(22, 2, 4))] { + for (name, template, expected) in cases { + let code = compile_for_version(&animate_component(name, template), version); + assert!(code.contains(expected), "{name} {version:?}\n{code}"); + } + } +} + +/// #701: from 20.2 a two-way `[(animate.x)]` / `bindon-animate.x` is an +/// animation binding with an animation listener, as ngtsc 22.2.4 / 20.2.0 emit. +#[test] +fn test_template_two_way_animate() { + let cases = [ + ( + "twoway", + r#"

"#, + r#"decls:1,vars:0,template:functionT_twoway_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p");i0.ɵɵanimateLeave(functionT_twoway_Template_animateleave_cb(){returnctx.e;});i0.ɵɵanimateLeaveListener(functionT_twoway_Template_p_animateleaveChange_0_listener(){returnctx.e;});i0.ɵɵdomElementEnd();}}"#, + ), + ( + "bindon", + r#"

"#, + r#"decls:1,vars:0,template:functionT_bindon_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p");i0.ɵɵanimateLeave(functionT_bindon_Template_animateleave_cb(){returnctx.e;});i0.ɵɵanimateLeaveListener(functionT_bindon_Template_p_animateleaveChange_0_listener(){returnctx.e;});i0.ɵɵdomElementEnd();}}"#, + ), + ( + "twowaymix", + r#"

"#, + r#"decls:1,vars:0,consts:[[3,"click"]],template:functionT_twowaymix_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p",0);i0.ɵɵanimateEnter(functionT_twowaymix_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave("x");i0.ɵɵanimateLeaveListener(functionT_twowaymix_Template_p_animateenterChange_0_listener(){returnctx.e;});i0.ɵɵdomListener("click",functionT_twowaymix_Template_p_click_0_listener(){returnctx.g(1);});i0.ɵɵdomElementEnd();}}"#, + ), + ]; + for version in [None, Some(AngularVersion::new(22, 2, 4)), Some(AngularVersion::new(20, 2, 0))] + { + for (name, template, expected) in cases { + let code = compile_for_version(&animate_component(name, template), version); + assert!(code.contains(expected), "{name} {version:?}\n{code}"); + assert!(!code.contains("twoWay"), "{name} {version:?}\n{code}"); + } + } +} diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index bc242dc98..41856a5ca 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1478,3 +1478,81 @@ fn test_link_host_animate_before_20_2() { assert!(!linked.contains("hostAttrs"), "{linked}"); } } + +/// #701: the partial declaration ngtsc 20.2.0 emits for the issue's template. +const ANIMATE_701_COMPONENT: &str = r#" +import * as i0 from "@angular/core"; +class T_issue { +} +T_issue.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.0", type: T_issue, isStandalone: true, selector: "t-issue", ngImport: i0, template: "

", isInline: true }); +"#; + +/// #701: before 20.2 the linker keeps template `animate.*` as a plain +/// attribute, property and listener, as Angular's 20.1.8 linker does. +#[test] +fn test_link_template_animate_before_20_2() { + let linked: String = + link_for_version(ANIMATE_701_COMPONENT, Some(AngularVersion::new(20, 1, 8))) + .split_whitespace() + .collect(); + assert!( + linked.contains(r#"decls:1,vars:1,consts:[["animate.leave","out",3,"animate.leave","animate.enter"]],template:functionT_issue_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p",0);i0.ɵɵdomListener("animate.leave",functionT_issue_Template_p_animate_leave_0_listener($event){returnctx.g($event);});i0.ɵɵdomElementEnd();}if((rf&2)){i0.ɵɵdomProperty("animate.enter",ctx.e);}}"#), + "{linked}" + ); + assert!(!linked.contains("ɵɵanimate"), "{linked}"); +} + +/// #701: Angular's 20.2.0 to 20.3.0 linker adds `ɵɵAnimationsFeature()` for a +/// template `animate.leave`, after the host fields and before `decls`, and +/// orders the animate ops like ngtsc. +#[test] +fn test_link_template_animate_leave_feature() { + let linked: String = + link_for_version(ANIMATE_701_COMPONENT, Some(AngularVersion::new(20, 2, 0))) + .split_whitespace() + .collect(); + assert!( + linked.contains(r#"selectors:[["t-issue"]],features:[i0.ɵɵAnimationsFeature()],decls:1,vars:0,template:functionT_issue_Template(rf,ctx){if((rf&1)){i0.ɵɵdomElementStart(0,"p");i0.ɵɵanimateEnter(functionT_issue_Template_animateenter_cb(){returnctx.e;});i0.ɵɵanimateLeave("out");i0.ɵɵanimateLeaveListener(functionT_issue_Template_p_animateleave_0_listener($event){returnctx.g($event);});i0.ɵɵdomElementEnd();}}"#), + "{linked}" + ); + + let cases = [ + (Some(AngularVersion::new(20, 3, 0)), true), + (Some(AngularVersion::new(20, 3, 1)), false), + (Some(AngularVersion::new(22, 2, 4)), false), + (None, false), + ]; + for (version, feature) in cases { + let linked = link_for_version(ANIMATE_701_COMPONENT, version); + assert_eq!(linked.contains("ɵɵAnimationsFeature()"), feature, "{version:?}\n{linked}"); + } + + // `animate.enter` alone needs no feature. + let enter_only = ANIMATE_701_COMPONENT.replace( + r#"template: "

""#, + r#"template: "

""#, + ); + assert!(enter_only.contains("animate.enter")); + let linked = link_for_version(&enter_only, Some(AngularVersion::new(20, 2, 0))); + assert!(!linked.contains("AnimationsFeature"), "{linked}"); +} + +/// #701: before 20.2 a host `animate.*` listener links to a plain `ɵɵlistener`. +#[test] +fn test_link_host_animate_listener_before_20_2() { + for code in host_declarations(r#"{ listeners: { "animate.enter": "g($event)" } }"#) { + let linked: String = link_for_version(&code, Some(AngularVersion::new(20, 1, 8))) + .split_whitespace() + .collect(); + assert!( + linked.contains(r#"i0.ɵɵlistener("animate.enter",functionD_animate_enter_HostBindingHandler($event){returnctx.g($event);});"#), + "{linked}" + ); + assert!(!linked.contains("ɵɵanimate"), "{linked}"); + + let linked: String = link_for_version(&code, Some(AngularVersion::new(20, 2, 0))) + .split_whitespace() + .collect(); + assert!(linked.contains("i0.ɵɵanimateEnterListener("), "{linked}"); + } +} diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 165b98f9d..86c403228 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -825,6 +825,7 @@ pub fn compile_for_hmr_sync( vars: output.vars, consts_js: output.consts_js.as_deref(), ng_content_selectors_js: output.ng_content_selectors_js.as_deref(), + animations_feature: output.animations_feature, }), styles: encapsulated_styles.as_deref(), declarations_js, From 80881924ce287e403b234e62b37413d7f2207e9a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:01:02 +0800 Subject: [PATCH 05/22] fix(linker): rebuild hostDirectives like Angular's linker (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Oxc linker copied the partial `hostDirectives` array into `ɵɵHostDirectivesFeature` as written. A `forwardRef` entry then ran while the class was still being defined and threw "Cannot access 'L' before initialization". Now each entry is read like Angular's `toHostDirectivesMetadata` and `createHostDirectivesFeatureArg`: the forwardRef is unwrapped, an entry with no mapping becomes the bare class, any forward reference wraps the list in `function () { return [...]; }`, and an empty list gets no feature. Mapping arrays are read into an object, as Angular does. A shape that cannot be read is still copied as written. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 84 ++++++++++++++++++- .../oxc_angular_compiler/tests/linker_test.rs | 61 ++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 4d40adbcc..9b87250d8 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -2178,7 +2178,9 @@ fn build_features( } // 2. HostDirectivesFeature — must come before InheritDefinitionFeature - if let Some(host_directives) = get_property_source(meta, "hostDirectives", source) { + if let Some(host_directives) = get_property_expression(meta, "hostDirectives") + .and_then(|expr| host_directives_feature_arg(expr, source)) + { features.push(format!("{ns}.\u{0275}\u{0275}HostDirectivesFeature({host_directives})")); } @@ -2213,6 +2215,86 @@ fn build_features( if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } } +/// The `ɵɵHostDirectivesFeature` argument for a partial `hostDirectives` +/// array, rebuilt like Angular's linker (`toHostDirectivesMetadata` then +/// `createHostDirectivesFeatureArg`): `{ directive: forwardRef(() => X) }` +/// becomes `X`, an entry with no mapping is the bare directive, and any +/// forward reference wraps the list in `function () { return [...]; }` so +/// it's read after the classes are defined. `None` for an empty array, which +/// gets no feature (`meta.hostDirectives?.length`). A shape this can't read +/// is copied as written. +fn host_directives_feature_arg(expr: &Expression<'_>, source: &str) -> Option { + let as_written = || { + let span = expr.span(); + Some(source[span.start as usize..span.end as usize].to_string()) + }; + let Expression::ArrayExpression(array) = expr else { return as_written() }; + if array.elements.is_empty() { + return None; + } + let mut entries = Vec::with_capacity(array.elements.len()); + let mut has_forward_ref = false; + for element in &array.elements { + let Some(Expression::ObjectExpression(object)) = element.as_expression() else { + return as_written(); + }; + let Some(directive) = get_property_expression(object, "directive") else { + return as_written(); + }; + let (directive, is_forward_ref) = extract_forward_ref(directive, source); + has_forward_ref |= is_forward_ref; + let mut mappings = Vec::new(); + for key in ["inputs", "outputs"] { + let Some(value) = get_property_expression(object, key) else { continue }; + let Some(mapping) = host_directive_binding_mapping(value) else { + return as_written(); + }; + if !mapping.is_empty() { + let pairs: Vec = mapping + .iter() + .flat_map(|(field, alias)| { + [escape_string(field, false), escape_string(alias, false)] + }) + .collect(); + mappings.push(format!("{key}: [{}]", pairs.join(", "))); + } + } + if mappings.is_empty() { + entries.push(directive.to_string()); + } else { + entries.push(format!("{{ directive: {directive}, {} }}", mappings.join(", "))); + } + } + let list = format!("[{}]", entries.join(", ")); + Some(if has_forward_ref { format!("function () {{ return {list}; }}") } else { list }) +} + +/// A partial host directive `inputs` / `outputs` array read like Angular's +/// `getHostDirectiveBindingMapping`: pairs collected into an object, so a +/// trailing odd element is dropped, a field listed twice keeps its first +/// place and its last alias, and array-index fields come first. `None` when +/// it isn't an array of strings. +fn host_directive_binding_mapping<'a>(expr: &'a Expression<'a>) -> Option> { + let Expression::ArrayExpression(array) = expr else { return None }; + let mut mapping: Vec<(&str, &str)> = Vec::new(); + for pair in array.elements.chunks_exact(2) { + let field = as_static_string(pair[0].as_expression()?)?; + let alias = as_static_string(pair[1].as_expression()?)?; + match mapping.iter_mut().find(|(f, _)| *f == field) { + Some(entry) => entry.1 = alias, + None => mapping.push((field, alias)), + } + } + // Array-index keys come first, in numeric order, as in a JS object. + let index = |key: &str| { + let canonical = + key == "0" || (!key.starts_with('0') && key.bytes().all(|b| b.is_ascii_digit())); + if canonical { key.parse::().ok().filter(|n| *n != u32::MAX) } else { None } + }; + mapping.sort_by_key(|(field, _)| index(field).map_or((1, 0), |n| (0, n))); + Some(mapping) +} + /// Whether the partial `host: { attributes, properties, listeners }` has an /// `animate.leave` key, as ngtsc's `hasAnimationHostBinding` checks. fn has_animate_leave_host(host: &ObjectExpression<'_>) -> bool { diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index bc242dc98..5af81a6cb 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1478,3 +1478,64 @@ fn test_link_host_animate_before_20_2() { assert!(!linked.contains("hostAttrs"), "{linked}"); } } + +/// `hostDirectives` is rebuilt like Angular's linker (`toHostDirectivesMetadata` +/// then `createHostDirectivesFeatureArg`), not copied: a forward reference is +/// unwrapped and wraps the whole list in a function, so the classes are read +/// after they are defined (#702). Expected text is what the +/// `@angular/compiler-cli` 22.2.4 babel linker writes for the same input; the +/// first four inputs are ngtsc's own partial output. +#[test] +fn test_link_host_directives_like_angular_linker() { + let cases = [ + ("[{ directive: i1.H }, { directive: i1.H }]", Some("[i1.H, i1.H]")), + ("[{ directive: i0.forwardRef(() => L) }]", Some("function () { return [L]; }")), + ( + r#"[{ directive: i0.forwardRef(() => L), inputs: ["color", "c"] }]"#, + Some(r#"function () { return [{ directive: L, inputs: ["color", "c"] }]; }"#), + ), + ( + "[{ directive: i1.H }, { directive: i0.forwardRef(() => L) }]", + Some("function () { return [i1.H, L]; }"), + ), + // Oxc's own partial output. + ( + r#"[{ directive: i0.forwardRef(function () { return L; }), inputs: ["color", "c"], outputs: ["changed", "ch"] }]"#, + Some( + r#"function () { return [{ directive: L, inputs: ["color", "c"], outputs: ["changed", "ch"] }]; }"#, + ), + ), + // Hand-written: no feature for an empty list, empty mappings give the + // bare class, and the pairs are read into an object (a trailing odd + // element dropped, the last alias kept, index keys first). + ("[]", None), + ("[{ directive: L, inputs: [], outputs: [] }]", Some("[L]")), + ( + r#"[{ directive: L, inputs: ["color", "a", "color", "b", "1", "one", "x"], outputs: ["changed"] }]"#, + Some(r#"[{ directive: L, inputs: ["1", "one", "color", "b"] }]"#), + ), + ]; + for (host_directives, expected) in cases { + let code = format!( + r#"import * as i0 from "@angular/core"; +import * as i1 from "./dep"; +export class Dir {{}} +Dir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "22.2.2", type: Dir, isStandalone: true, selector: "[d]", hostDirectives: {host_directives}, ngImport: i0 }}); +export class L {{}} +"# + ); + let allocator = Allocator::default(); + let linked: String = link(&allocator, &code, "test.mjs").code.split_whitespace().collect(); + match expected { + Some(arg) => { + let arg: String = arg.split_whitespace().collect(); + assert!( + linked.contains(&format!("features:[i0.ɵɵHostDirectivesFeature({arg})]")), + "{host_directives}:\n{linked}" + ); + } + None => assert!(!linked.contains("HostDirectivesFeature"), "{linked}"), + } + assert!(!linked.contains("hostDirectives"), "{linked}"); + } +} From bdf5dcb83459ad0743317d59b79419bafe71f318 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:01:11 +0800 Subject: [PATCH 06/22] fix: align hostDirectives pairs and references with ngtsc (#702) - Partial output wrote each input/output pair as [alias, field]. Both partial emitters now share the full compile's `create_host_directive_mappings_array`, which writes [field, alias]. - The .d.ts wrote `{ "alias": "field" }` with no trailing `;`. It now writes `{ "field": "alias"; }` like ngtsc, from one shared helper. - A component's imported host directive is referenced by the module's export name, as the directive path already did: `i1.H` for `import { H as P }`, `i1.default` for a default import. The partial component now writes `i1.H` too, and builds the factory first so its deps take the lower `iN`, as ngtsc does. - `dep.H` through `import * as dep` was dropped from the JS and the .d.ts. The evaluator now keeps the namespace's local name on the reference, and the module comes from the import map entry of that name (so `resolved_imports` applies). The export name is never looked up as a local name, so another import called `H` is not picked. - A string export name (`import { "s-dir" as SDir }`, `dep["s-dir"]`) is written `i1["s-dir"]`. ngtsc writes `i1.s-dir`, which does not parse. Co-Authored-By: Claude Opus 5.5 --- .../src/component/decorator.rs | 16 +- .../src/component/definition.rs | 45 +- .../src/component/metadata.rs | 15 +- .../oxc_angular_compiler/src/component/mod.rs | 1 + .../src/component/transform.rs | 143 ++++-- .../src/directive/compiler.rs | 3 +- .../src/directive/decorator.rs | 66 ++- .../src/directive/evaluator.rs | 43 +- .../oxc_angular_compiler/src/directive/mod.rs | 5 +- crates/oxc_angular_compiler/src/dts.rs | 133 +++--- crates/oxc_angular_compiler/src/output/ast.rs | 28 ++ .../src/partial/component.rs | 47 +- .../src/partial/directive.rs | 24 +- .../tests/dts_type_reference_test.rs | 2 +- .../tests/host_directives_ngtsc_test.rs | 412 ++++++++++++++++++ .../test/host-directives-runtime.test.ts | 193 ++++++++ 16 files changed, 956 insertions(+), 220 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/host_directives_ngtsc_test.rs create mode 100644 napi/angular-compiler/test/host-directives-runtime.test.ts diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index fbf201915..c3eee799c 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -793,9 +793,21 @@ fn extract_host_directives<'a>( for entry in entries { let has_mapping_keys = entry.has_mapping_keys(); let mut meta = HostDirectiveMetadata::new(allocator, entry.name); - // Look up the source module from the import map - if let Some(import_info) = import_map.get(&entry.name) { + if let Some(namespace) = entry.namespace { + // `ns.X`: the export `X` of the namespace import's module. The + // export name is never looked up in the import map, where it may + // name an unrelated import. + meta.namespace = Some(namespace); + meta.source_module = import_map + .get(&namespace) + .filter(|info| !info.is_named_import) + .map(|info| info.source_module); + } else if let Some(import_info) = import_map.get(&entry.name) { + // An imported class is referenced by its export name: `H` for + // `import { H as P }`, `default` for a default import, `s-dir` + // for `import { "s-dir" as SDir }`. meta.source_module = Some(import_info.source_module); + meta.export_name = import_info.string_export_name.or(import_info.imported_name); } meta.is_forward_reference = entry.is_forward_reference; meta.has_mapping_keys = has_mapping_keys; diff --git a/crates/oxc_angular_compiler/src/component/definition.rs b/crates/oxc_angular_compiler/src/component/definition.rs index 2e5e61d96..386c9cdc9 100644 --- a/crates/oxc_angular_compiler/src/component/definition.rs +++ b/crates/oxc_angular_compiler/src/component/definition.rs @@ -32,7 +32,8 @@ use crate::factory::create_invalid_factory_call; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, FnParam, FunctionExpr, InstantiateExpr, InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, - LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, ReturnStatement, + LiteralValue, OutputAstBuilder, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, + ReturnStatement, }; use crate::pipeline::compilation::{ComponentCompilationJob, ConstValue}; use crate::pipeline::emit::HostBindingCompilationResult; @@ -1176,39 +1177,37 @@ fn generate_host_directives_feature<'a>( /// Create a directive reference expression. /// /// For imported directives (those with a `source_module`), generates namespaced -/// references like `i1.MyDirective`. For local directives, generates bare -/// variable references like `MyDirective`. +/// references to the module's export, like ngtsc's `toR3Reference`: +/// `i1.MyDirective` (also for `import { MyDirective as Alias }`), `i1.default` +/// for a default import, and `i1["s-dir"]` for a string export name. For local +/// directives, generates bare variable references like `MyDirective`. /// /// See: packages/compiler/src/render3/view/compiler.ts:689-692 -fn create_directive_reference<'a>( +pub(crate) fn create_directive_reference<'a>( allocator: &'a Allocator, directive: &HostDirectiveMetadata<'a>, namespace_registry: &mut NamespaceRegistry<'a>, ) -> OutputExpression<'a> { + let export = directive.export_name.unwrap_or(directive.directive); if let Some(ref source_module) = directive.source_module { // Imported directive - use namespace.DirectiveName (e.g., i1.ExternalDir) let namespace = namespace_registry.get_or_assign(source_module); - return OutputExpression::ReadProp(Box::new_in( - ReadPropExpr { - receiver: Box::new_in( - OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: namespace, source_span: None }, - &allocator, - )), - &allocator, - ), - name: directive.directive.clone(), - optional: false, - source_span: None, - }, - &allocator, - )); + return OutputAstBuilder::export_read( + allocator, + OutputAstBuilder::variable(allocator, namespace), + export, + ); + } + // `ns.X` whose namespace import has no module to alias: as written. + if let Some(namespace) = directive.namespace { + return OutputAstBuilder::export_read( + allocator, + OutputAstBuilder::variable(allocator, namespace), + export, + ); } // No source module - use bare type name (local directive) - OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: directive.directive.clone(), source_span: None }, - &allocator, - )) + OutputAstBuilder::variable(allocator, directive.directive) } /// Create the host directives argument array. diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 9aaf07cf2..45ad98fea 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -816,7 +816,8 @@ pub struct ImportRef<'a> { /// See: packages/compiler/src/render3/view/compiler.ts:683-723 #[derive(Debug)] pub struct HostDirectiveMetadata<'a> { - /// The directive class name. + /// The directive class name as written in this file: the class's own + /// name, the local name of its import, or for `ns.X` the export name `X`. pub directive: Ident<'a>, /// The source module of the directive (e.g., "@angular/common", "./my-directive"). @@ -824,6 +825,16 @@ pub struct HostDirectiveMetadata<'a> { /// `None` for local directives defined in the same file. pub source_module: Option>, + /// The name `source_module` exports the class under, when it isn't + /// `directive`: `H` for `import { H as P }`, `default` for a default + /// import, `s-dir` for `import { "s-dir" as SDir }`. + pub export_name: Option>, + + /// For `ns.X` read off `import * as ns`, the local name `ns`. It is only + /// used when the namespace import has no `source_module`, to keep the + /// reference as written. + pub namespace: Option>, + /// Input mappings: (publicName, internalName) pairs. /// Empty if no inputs are exposed. pub inputs: Vec<'a, (Ident<'a>, Ident<'a>)>, @@ -847,6 +858,8 @@ impl<'a> HostDirectiveMetadata<'a> { Self { directive, source_module: None, + export_name: None, + namespace: None, inputs: Vec::new_in(&allocator), outputs: Vec::new_in(&allocator), is_forward_reference: false, diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 658042b1f..998c189fd 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -25,6 +25,7 @@ mod transform; pub use cross_file_elision::CrossFileAnalyzer; pub use decorator::extract_component_metadata; pub(crate) use decorator::find_component_decorator; +pub(crate) use definition::create_directive_reference; pub use definition::{ ComponentDefinitions, const_value_to_expression, generate_component_definitions, }; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b9b2ba12a..43c223c05 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -61,7 +61,8 @@ use crate::ng_module::{ }; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, DeclareFunctionStmt, DeclareVarStmt, FunctionExpr, - OutputExpression, OutputStatement, RawSourceExpr, ReadPropExpr, ReadVarExpr, StmtModifier, + LiteralValue, OutputAstBuilder, OutputExpression, OutputStatement, RawSourceExpr, ReadPropExpr, + ReadVarExpr, StmtModifier, }; use crate::output::emitter::JsEmitter; use crate::parser::ParseTemplateOptions; @@ -937,17 +938,49 @@ fn dts_type_ref<'a>( ) -> String { match import_map.get(&Ident::from(local)) { Some(info) if info.is_named_import => { - let namespace = registry.get_or_assign(&info.source_module); - if let Some(name) = &info.string_export_name { - return format!("{namespace}[{}]", crate::directive::ts_string_literal(name)); - } - let export = info.imported_name.as_ref().map_or(local, |name| name.as_str()); - format!("{namespace}.{export}") + let export = info + .string_export_name + .as_ref() + .or(info.imported_name.as_ref()) + .map_or(local, |name| name.as_str()); + dts_export_ref(&info.source_module, export, registry) } _ => local.to_string(), } } +/// The `.d.ts` type reference of the export `export` of `module`: `i1.Dep`, +/// or `i1["s-dir"]` for an export name that isn't an identifier name. +fn dts_export_ref<'a>( + module: &Ident<'a>, + export: &str, + registry: &mut NamespaceRegistry<'a>, +) -> String { + dts::export_member(registry.get_or_assign(module).as_str(), export) +} + +/// For a host directive `ns.X` / `ns["x"]` read off `import * as ns`, the +/// namespace's import and the export name. +fn host_directive_namespace_member<'e, 'a>( + expr: &'e OutputExpression<'a>, + import_map: &'e ImportMap<'a>, +) -> Option<(&'e ImportInfo<'a>, Ident<'a>)> { + let (receiver, export) = match expr { + OutputExpression::ReadProp(prop) => (&prop.receiver, prop.name), + OutputExpression::ReadKey(key) => match &*key.index { + OutputExpression::Literal(lit) => match &lit.value { + LiteralValue::String(name) => (&key.receiver, *name), + _ => return None, + }, + _ => return None, + }, + _ => return None, + }; + let OutputExpression::ReadVar(namespace) = &**receiver else { return None }; + let info = import_map.get(&namespace.name).filter(|info| !info.is_named_import)?; + Some((info, export)) +} + /// `i1.name` for the export `name` of `specifier` in the `.d.ts`. fn dts_module_ref<'a>( allocator: &'a Allocator, @@ -962,7 +995,10 @@ fn dts_module_ref<'a>( /// Resolves namespace imports for host directive references in `R3HostDirectiveMetadata`. /// /// Replaces bare `ReadVar("X")` references with namespace-prefixed `ReadProp(ReadVar("i1"), "X")` -/// for any host directive that has a corresponding import in the import map. +/// for any host directive that has a corresponding import in the import map, +/// and `ns.X` (`import * as ns`) with `i1.X`. Like ngtsc's `toR3Reference`, the +/// module's export name is used: `i1.Foo` for `import { Foo as Bar }`, +/// `i1.default` for a default import, `i1["s-dir"]` for a string export name. /// This ensures the compiled output works correctly even after import elision. fn resolve_host_directive_namespaces<'a>( allocator: &'a Allocator, @@ -971,29 +1007,25 @@ fn resolve_host_directive_namespaces<'a>( namespace_registry: &mut NamespaceRegistry<'a>, ) { for hd in host_directives.iter_mut() { - // Only process bare variable references (ReadVar) - let OutputExpression::ReadVar(ref var) = hd.directive else { continue }; - let name = &var.name; - // Look up this identifier in the import map - let Some(import_info) = import_map.get(name) else { continue }; - // Replace with namespace-prefixed reference: i1.BrnTooltipTrigger instead of BrnTooltipTrigger - let namespace = namespace_registry.get_or_assign(&import_info.source_module); - hd.directive = OutputExpression::ReadProp(oxc_allocator::Box::new_in( - ReadPropExpr { - receiver: oxc_allocator::Box::new_in( - OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { name: namespace, source_span: None }, - &allocator, - )), - &allocator, - ), - // Export name when aliased (`Foo as Bar` → `i1.Foo`). - name: import_info.imported_name.clone().unwrap_or_else(|| name.clone()), - optional: false, - source_span: None, - }, - &allocator, - )); + let (source_module, export) = match &hd.directive { + OutputExpression::ReadVar(var) => { + let Some(info) = import_map.get(&var.name) else { continue }; + let export = info.string_export_name.or(info.imported_name).unwrap_or(var.name); + (info.source_module, export) + } + expr => { + let Some((info, export)) = host_directive_namespace_member(expr, import_map) else { + continue; + }; + (info.source_module, export) + } + }; + let namespace = namespace_registry.get_or_assign(&source_module); + hd.directive = OutputAstBuilder::export_read( + allocator, + OutputAstBuilder::variable(allocator, namespace), + export, + ); } } @@ -4491,12 +4523,18 @@ pub fn transform_angular_file( let host_directive_types: Vec = metadata .host_directives .iter() - .map(|hd| { - dts_type_ref( - hd.directive.as_str(), - &import_map, + .map(|hd| match (&hd.source_module, hd.namespace) { + // `ns.X`, or an import: the module's export. + (Some(module), _) => dts_export_ref( + module, + hd.export_name.unwrap_or(hd.directive).as_str(), &mut dts_namespace_registry, - ) + ), + // `ns.X` with no module to alias: as written. + (None, Some(namespace)) => { + dts::export_member(&namespace, &hd.directive) + } + (None, None) => hd.directive.to_string(), }) .collect(); result.dts_declarations.push(dts_entry( @@ -4599,7 +4637,14 @@ pub fn transform_angular_file( &import_map, &mut dts_namespace_registry, ), - _ => dts::extract_directive_name_from_expr(&hd.directive), + expr => match host_directive_namespace_member(expr, &import_map) { + Some((info, export)) => dts_export_ref( + &info.source_module, + export.as_str(), + &mut dts_namespace_registry, + ), + None => dts::extract_directive_name_from_expr(expr), + }, }) .collect() } else { @@ -6290,6 +6335,21 @@ fn compile_component_partial<'a>( pool_starting_index: u32, namespace_registry: &mut NamespaceRegistry<'a>, ) -> FullCompilationResult<'a> { + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); + // ngtsc emits `ɵfac` before `ɵcmp`, so the factory's dependencies take + // their namespace aliases before the host directives do. + let fac_expr = crate::partial::component::compile_declare_factory_for_component( + allocator, + core_namespace, + version, + metadata, + namespace_registry, + ); + let host_directive_refs: Vec> = metadata + .host_directives + .iter() + .map(|hd| crate::component::create_directive_reference(allocator, hd, namespace_registry)) + .collect(); let inputs = crate::partial::PartialComponentInputs { template, is_inline: metadata.template.is_some(), @@ -6297,8 +6357,8 @@ fn compile_component_partial<'a>( view_queries, defer_block_dependencies, import_scopes, + host_directive_refs: &host_directive_refs, }; - let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let cmp_expr = crate::partial::compile_declare_component_from_metadata( allocator, core_namespace, @@ -6306,13 +6366,6 @@ fn compile_component_partial<'a>( metadata, &inputs, ); - let fac_expr = crate::partial::component::compile_declare_factory_for_component( - allocator, - core_namespace, - version, - metadata, - namespace_registry, - ); // Detect `@defer` block presence by a cheap string scan — partial // mode skips the template pipeline, but the caller's class-metadata diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 80445886f..4de0ba8ba 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -1029,7 +1029,8 @@ pub fn create_host_directives_feature_arg<'a>( /// Format: `['internalName', 'publicName', 'internalName2', 'publicName2']` /// /// Mirrors Angular's `createHostDirectivesMappingArray` in `view/compiler.ts`. -fn create_host_directive_mappings_array<'a>( +/// The partial declarations write the same array. +pub(crate) fn create_host_directive_mappings_array<'a>( allocator: &'a Allocator, mappings: &[(Ident<'a>, Ident<'a>)], ) -> OutputExpression<'a> { diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 7efed583a..c02fcb4ab 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -187,8 +187,18 @@ pub fn extract_directive_metadata<'a>( } "hostDirectives" => { for entry in evaluate_host_directives(allocator, &prop.value, consts) { + // `ns.X` stays a read off the namespace import; the + // transform swaps `ns` for its own namespace alias. + let directive = match entry.namespace { + Some(namespace) => OutputAstBuilder::export_read( + allocator, + OutputAstBuilder::variable(allocator, namespace), + entry.name, + ), + None => OutputAstBuilder::variable(allocator, entry.name), + }; builder = builder.add_host_directive(R3HostDirectiveMetadata { - directive: OutputAstBuilder::variable(allocator, entry.name), + directive, is_forward_reference: entry.is_forward_reference, has_mapping_keys: entry.has_mapping_keys(), inputs: entry.inputs.unwrap_or_else(|| Vec::new_in(&allocator)), @@ -888,7 +898,7 @@ fn imported_transform_error( ) -> Option<(String, Span)> { let computed = matches!( transform.value, - Value::Reference { kind: RefKind::Import { namespace_member: false, local: None }, .. } + Value::Reference { kind: RefKind::Import { namespace_member: false, local: None, .. }, .. } ); computed.then(|| (value_error(subject, String::new, &transform.value), span)) } @@ -915,7 +925,9 @@ fn reference_identity<'p>(transform: &'p Prop<'_>, consts: &StringConsts<'_>) -> { Some(name) } - Value::Reference { kind: RefKind::Import { namespace_member: false, local }, .. } => *local, + Value::Reference { + kind: RefKind::Import { namespace_member: false, local, .. }, .. + } => *local, Value::Reference { name, kind: RefKind::Global | RefKind::Ambient } => Some(name), _ => None, } @@ -2192,8 +2204,11 @@ fn extract_host_metadata<'a>( /// One `hostDirectives` entry, as ngtsc's `extractHostDirectives` reads it. pub struct HostDirectiveEntry<'a> { /// The name the directive class is referenced by in this file: its own - /// name, or the local name of its import. + /// name, or the local name of its import. For `ns.X` read off + /// `import * as ns`, the export name `X`. pub name: Ident<'a>, + /// For `ns.X`, the local name `ns` of the `import * as ns`. + pub namespace: Option>, /// Written as `forwardRef(() => X)` (ngtsc's synthetic reference). pub is_forward_reference: bool, /// The `inputs` mappings as (publicName, internalName) pairs, or `None` @@ -2268,34 +2283,53 @@ pub fn evaluate_host_directives<'a>( .filter_map(|(value, written)| { if let Value::Object(_) = &value { let reference = value.prop("directive")?; - let (name, is_forward_reference) = directive(&reference.value, reference.expr)?; + let ((name, namespace), is_forward_reference) = + directive(&reference.value, reference.expr)?; let mappings = |key| value.prop(key).map(|p| host_directive_mappings(allocator, &p.value)); Some(HostDirectiveEntry { name, + namespace, is_forward_reference, inputs: mappings("inputs"), outputs: mappings("outputs"), }) } else { - let (name, is_forward_reference) = directive(&value, written)?; - Some(HostDirectiveEntry { name, is_forward_reference, inputs: None, outputs: None }) + let ((name, namespace), is_forward_reference) = directive(&value, written)?; + Some(HostDirectiveEntry { + name, + namespace, + is_forward_reference, + inputs: None, + outputs: None, + }) } }) .collect() } -/// The name a host directive class reference is written by in this file. -fn host_directive_name<'a>(allocator: &'a Allocator, value: &Value<'a>) -> Option> { - let name = match value { - Value::Reference { name, kind: RefKind::Class(_) | RefKind::Ambient } => name.as_str(), +/// The name a host directive class reference is written by in this file, +/// and for `ns.X` (`import * as ns`) the export name `X` and the namespace's +/// local name `ns`. A member of anything else (`ns.sub.X`) has no name here. +fn host_directive_name<'a>( + allocator: &'a Allocator, + value: &Value<'a>, +) -> Option<(Ident<'a>, Option>)> { + let alloc = |s: &str| Ident::from(allocator.alloc_str(s)); + match value { + Value::Reference { name, kind: RefKind::Class(_) | RefKind::Ambient } => { + Some((alloc(name), None)) + } Value::Reference { - kind: RefKind::Import { namespace_member: false, local: Some(local) }, + kind: RefKind::Import { namespace_member: false, local: Some(local), .. }, .. - } => local, - _ => return None, - }; - Some(Ident::from(allocator.alloc_str(name))) + } => Some((alloc(local), None)), + Value::Reference { + name, + kind: RefKind::Import { namespace_member: true, namespace: Some(namespace), .. }, + } => Some((alloc(name), Some(alloc(namespace)))), + _ => None, + } } /// A host directive `inputs` / `outputs` array as (publicName, internalName) diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index e55d73184..8e39be9b9 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -1181,6 +1181,10 @@ pub(crate) enum RefKind<'a> { /// this file (`ba` for `import { booleanAttribute as ba }`), which is /// what ngtsc emits for it (the reference's identity in this file). local: Option<&'a str>, + /// For `ns.x` itself (not a value computed from it), the local name + /// `ns` of the `import * as ns` it is read off; the reference's name + /// is then the export name `x`. + namespace: Option<&'a str>, }, /// An identifier with no declaration in this file that names a standard /// ECMAScript global (see [`ES_GLOBALS`]). @@ -1215,8 +1219,12 @@ pub(crate) enum Value<'a> { Array(Vec>), Object(Vec>), /// `import * as ns`, with the module specifier it was imported from so - /// member access can ask the resolver about `ns`'s exports. - Module(&'a str), + /// member access can ask the resolver about `ns`'s exports, and the local + /// name `ns` of the import. + Module { + specifier: &'a str, + local: &'a str, + }, Reference { name: String, kind: RefKind<'a>, @@ -1405,7 +1413,10 @@ impl<'a> Value<'a> { fn computed(self) -> Self { match self { Value::Reference { name, kind: RefKind::Import { namespace_member, .. } } => { - Value::Reference { name, kind: RefKind::Import { namespace_member, local: None } } + Value::Reference { + name, + kind: RefKind::Import { namespace_member, local: None, namespace: None }, + } } value => value, } @@ -1457,7 +1468,7 @@ impl<'a> Value<'a> { .collect::>() .join(", ") ), - Value::Module(_) => "(module)".into(), + Value::Module { .. } => "(module)".into(), _ if self.is_dynamic() => "(not statically analyzable)".into(), Value::Reference { name, .. } | Value::Enum { name, .. } => name.clone(), Value::Builtin(_) => "Function".into(), @@ -1958,7 +1969,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { // The resolver enumerates them; an export it can't // read stands for ngtsc's `DynamicValue`. #[cfg(feature = "cross_file_elision")] - Value::Module(module) => match self.module_exports(module) { + Value::Module { specifier, .. } => match self.module_exports(specifier) { Some(exports) if self.spend(exports.len() as u32) => { props.extend(exports); } @@ -2171,10 +2182,14 @@ impl<'s, 'a> Evaluator<'s, 'a> { let declared = if imported == "default" { name } else { imported }; Value::Reference { name: declared.into(), - kind: RefKind::Import { namespace_member: false, local: Some(name) }, + kind: RefKind::Import { + namespace_member: false, + local: Some(name), + namespace: None, + }, } } - None => Value::Module(import.module), + None => Value::Module { specifier: import.module, local: name }, }; } match name { @@ -2486,7 +2501,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { let imported = matches!(left, TSTypeName::IdentifierReference(id) if self.consts.scope().imports.contains_key(id.name.as_str())); let kind = if imported { - RefKind::Import { namespace_member: false, local: None } + RefKind::Import { namespace_member: false, local: None, namespace: None } } else { RefKind::Other }; @@ -2709,18 +2724,22 @@ impl<'s, 'a> Evaluator<'s, 'a> { Value::String(s) if matches!(key, Key::Str("concat")) => { Value::Builtin(Builtin::StringConcat(s)) } - Value::Module(_module) => { + Value::Module { specifier: _specifier, local } => { // `ns.x`: the resolver answers with `x`'s value in `module`, // like the identifier path does for `import { x }`. #[cfg(feature = "cross_file_elision")] if let Some(resolver) = self.consts.resolver() - && let Some(value) = resolver.resolve(_module, &key_str()) + && let Some(value) = resolver.resolve(_specifier, &key_str()) { return value.to_value(); } Value::Reference { name: key_str(), - kind: RefKind::Import { namespace_member: true, local: None }, + kind: RefKind::Import { + namespace_member: true, + local: None, + namespace: Some(local), + }, } } Value::Reference { kind: RefKind::Class(class), .. } => { @@ -2843,7 +2862,7 @@ pub(crate) fn transform_error<'a>( // a value computed from it (`!FLAG`, `make()`), which the caller // reports as imported. Value::Reference { - kind: RefKind::Import { namespace_member: false, local: Some(_) }, + kind: RefKind::Import { namespace_member: false, local: Some(_), .. }, .. } => { return clash(); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 4ead4cf8a..49f28a3a0 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -25,7 +25,10 @@ pub use compiler::{ DirectiveCompileResult, compile_directive, compile_directive_from_metadata, create_inputs_literal, create_outputs_literal, }; -pub(crate) use compiler::{create_host_directives_feature_arg, parse_host_property_name}; +pub(crate) use compiler::{ + create_host_directive_mappings_array, create_host_directives_feature_arg, + parse_host_property_name, +}; pub(crate) use decorator::evaluate_host_directives; pub(crate) use decorator::find_directive_decorator; pub(crate) use decorator::{ diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index 94a711890..cb757a02e 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -704,44 +704,10 @@ fn generate_host_directives_type_from_component( .iter() .enumerate() .map(|(i, hd)| { - let directive_name = types.get(i).map_or(hd.directive.as_str(), String::as_str); - let directive = format!("typeof {directive_name}"); - let inputs = if hd.inputs.is_empty() { - "{}".to_string() - } else { - let input_entries: Vec = hd - .inputs - .iter() - .map(|(public, internal)| { - format!( - "\"{}\": \"{}\"", - escape_dts_string(public.as_str()), - escape_dts_string(internal.as_str()) - ) - }) - .collect(); - format!("{{ {} }}", input_entries.join("; ")) - }; - let outputs = if hd.outputs.is_empty() { - "{}".to_string() - } else { - let output_entries: Vec = hd - .outputs - .iter() - .map(|(public, internal)| { - format!( - "\"{}\": \"{}\"", - escape_dts_string(public.as_str()), - escape_dts_string(internal.as_str()) - ) - }) - .collect(); - format!("{{ {} }}", output_entries.join("; ")) - }; - format!("{{ directive: {directive}; inputs: {inputs}; outputs: {outputs}; }}") + let directive = types.get(i).map_or(hd.directive.as_str(), String::as_str); + host_directive_type(directive, &hd.inputs, &hd.outputs) }) .collect(); - format!("[{}]", entries.join(", ")) } @@ -756,50 +722,49 @@ fn generate_host_directives_type_from_directive( .iter() .enumerate() .map(|(i, hd)| { - let directive_name = types + let directive = types .get(i) .cloned() .unwrap_or_else(|| extract_directive_name_from_expr(&hd.directive)); - let directive = format!("typeof {directive_name}"); - let inputs = if hd.inputs.is_empty() { - "{}".to_string() - } else { - let input_entries: Vec = hd - .inputs - .iter() - .map(|(public, internal)| { - format!( - "\"{}\": \"{}\"", - escape_dts_string(public.as_str()), - escape_dts_string(internal.as_str()) - ) - }) - .collect(); - format!("{{ {} }}", input_entries.join("; ")) - }; - let outputs = if hd.outputs.is_empty() { - "{}".to_string() - } else { - let output_entries: Vec = hd - .outputs - .iter() - .map(|(public, internal)| { - format!( - "\"{}\": \"{}\"", - escape_dts_string(public.as_str()), - escape_dts_string(internal.as_str()) - ) - }) - .collect(); - format!("{{ {} }}", output_entries.join("; ")) - }; - format!("{{ directive: {directive}; inputs: {inputs}; outputs: {outputs}; }}") + host_directive_type(&directive, &hd.inputs, &hd.outputs) }) .collect(); - format!("[{}]", entries.join(", ")) } +/// One host directive's type, like ngtsc's `createHostDirectivesType`: +/// `{ directive: typeof X; inputs: { "field": "alias"; }; outputs: {}; }`. +/// The mappings are stored as (publicName, internalName) pairs and written +/// keyed by the host directive's own field, as ngtsc's +/// `stringMapAsLiteralExpression` writes its `{ field: alias }` map. +fn host_directive_type( + directive: &str, + inputs: &[(Ident<'_>, Ident<'_>)], + outputs: &[(Ident<'_>, Ident<'_>)], +) -> String { + let map = |pairs: &[(Ident<'_>, Ident<'_>)]| { + if pairs.is_empty() { + return "{}".to_string(); + } + let entries: String = pairs + .iter() + .map(|(public, internal)| { + format!( + " \"{}\": \"{}\";", + escape_dts_string(internal.as_str()), + escape_dts_string(public.as_str()) + ) + }) + .collect(); + format!("{{{entries} }}") + }; + format!( + "{{ directive: typeof {directive}; inputs: {}; outputs: {}; }}", + map(inputs), + map(outputs) + ) +} + /// Extract a directive name from an `OutputExpression`. /// /// Handles: @@ -817,6 +782,18 @@ pub(crate) fn extract_directive_name_from_expr( let receiver = extract_directive_name_from_expr(&read_prop.receiver); format!("{}.{}", receiver, read_prop.name.as_str()) } + crate::output::ast::OutputExpression::ReadKey(read_key) => { + let receiver = extract_directive_name_from_expr(&read_key.receiver); + match &*read_key.index { + crate::output::ast::OutputExpression::Literal(lit) => match &lit.value { + crate::output::ast::LiteralValue::String(name) => { + export_member(&receiver, name) + } + _ => panic!("Unexpected key in host directive type"), + }, + _ => panic!("Unexpected key in host directive type"), + } + } crate::output::ast::OutputExpression::External(external) => match &external.value.name { Some(name) => name.as_str().to_string(), None => panic!("ExternalExpr in host directive has no export name"), @@ -830,6 +807,18 @@ pub(crate) fn extract_directive_name_from_expr( } } +/// The type of the export `export` of the namespace `base`: `i1.Dep`, or +/// `i1["s-dir"]` for an export name that isn't an identifier name (TS reads +/// `typeof i1["s-dir"]` as `(typeof i1)["s-dir"]`; ngtsc writes +/// `typeof i1.s-dir`, which does not parse). +pub(crate) fn export_member(base: &str, export: &str) -> String { + if oxc_syntax::identifier::is_identifier_name(export) { + format!("{base}.{export}") + } else { + format!("{base}[{}]", crate::directive::ts_string_literal(export)) + } +} + /// Escape a string for use in a TypeScript `.d.ts` string literal type. fn escape_dts_string(s: &str) -> String { let mut out = String::with_capacity(s.len()); diff --git a/crates/oxc_angular_compiler/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index bcf974335..dec3c3309 100644 --- a/crates/oxc_angular_compiler/src/output/ast.rs +++ b/crates/oxc_angular_compiler/src/output/ast.rs @@ -1736,6 +1736,34 @@ impl OutputAstBuilder { ) -> OutputExpression<'a> { OutputExpression::ReadVar(Box::new_in(ReadVarExpr { name, source_span: None }, &allocator)) } + + /// Read the export `name` off a namespace object: `ns.name`, or + /// `ns["s-dir"]` when the name is not an identifier name (a string + /// export, `export { X as "s-dir" }`). ngtsc writes `ns.s-dir` there, + /// which does not parse. + pub fn export_read<'a>( + allocator: &'a oxc_allocator::Allocator, + namespace: OutputExpression<'a>, + name: Ident<'a>, + ) -> OutputExpression<'a> { + let receiver = Box::new_in(namespace, &allocator); + if oxc_syntax::identifier::is_identifier_name(name.as_str()) { + OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { receiver, name, optional: false, source_span: None }, + &allocator, + )) + } else { + OutputExpression::ReadKey(Box::new_in( + ReadKeyExpr { + receiver, + index: Box::new_in(Self::string(allocator, name), &allocator), + optional: false, + source_span: None, + }, + &allocator, + )) + } + } } // ============================================================================ diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 671b731a0..9b35d02a5 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -42,7 +42,7 @@ use crate::component::{ HostMetadata, ImportRef, NamespaceRegistry, TemplateDependency, TemplateDependencyKind, ViewEncapsulation, }; -use crate::directive::{R3InputMetadata, R3QueryMetadata}; +use crate::directive::{R3InputMetadata, R3QueryMetadata, create_host_directive_mappings_array}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, @@ -78,6 +78,10 @@ pub struct PartialComponentInputs<'a, 'm> { /// the directives and pipes it exports, when cross-file analysis read /// them (#655). pub import_scopes: &'m [(Ident<'a>, Vec<'a, TemplateDependency<'a>>)], + /// Each host directive's reference (`i1.H`, by the module's export + /// name), in `meta.host_directives` order. A host directive without + /// one is written by its name in this file. + pub host_directive_refs: &'m [OutputExpression<'a>], } /// Emits the `ɵɵngDeclareComponent` call for a component's `ɵcmp` static. @@ -214,7 +218,12 @@ pub fn compile_declare_component_from_metadata<'a>( if !meta.host_directives.is_empty() { entries.push(LiteralMapEntry::new( Ident::from("hostDirectives"), - create_host_directives_array(allocator, core_namespace, &meta.host_directives), + create_host_directives_array( + allocator, + core_namespace, + &meta.host_directives, + inputs.host_directive_refs, + ), false, )); } @@ -648,15 +657,19 @@ fn create_host_directives_array<'a>( allocator: &'a Allocator, core_namespace: &'a str, host_directives: &Vec<'a, HostDirectiveMetadata<'a>>, + refs: &[OutputExpression<'a>], ) -> OutputExpression<'a> { let mut entries: Vec<'a, OutputExpression<'a>> = Vec::with_capacity_in(host_directives.len(), &allocator); - for hd in host_directives { + for (i, hd) in host_directives.iter().enumerate() { let mut hd_entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); - let directive_expr = OutputExpression::ReadVar(Box::new_in( - ReadVarExpr { name: hd.directive.clone(), source_span: None }, - &allocator, - )); + let directive_expr = match refs.get(i) { + Some(reference) => reference.clone_in(allocator), + None => OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: hd.directive.clone(), source_span: None }, + &allocator, + )), + }; let directive_expr = if hd.is_forward_reference { wrap_forward_ref(allocator, core_namespace, directive_expr) } else { @@ -667,14 +680,14 @@ fn create_host_directives_array<'a>( if !hd.inputs.is_empty() { hd_entries.push(LiteralMapEntry::new( Ident::from("inputs"), - host_directives_mapping_array(allocator, &hd.inputs), + create_host_directive_mappings_array(allocator, &hd.inputs), false, )); } if !hd.outputs.is_empty() { hd_entries.push(LiteralMapEntry::new( Ident::from("outputs"), - host_directives_mapping_array(allocator, &hd.outputs), + create_host_directive_mappings_array(allocator, &hd.outputs), false, )); } @@ -689,22 +702,6 @@ fn create_host_directives_array<'a>( )) } -fn host_directives_mapping_array<'a>( - allocator: &'a Allocator, - pairs: &Vec<'a, (Ident<'a>, Ident<'a>)>, -) -> OutputExpression<'a> { - let mut elements: Vec<'a, OutputExpression<'a>> = - Vec::with_capacity_in(pairs.len() * 2, &allocator); - for (public_name, alias) in pairs { - elements.push(string_literal_owned(allocator, public_name.clone())); - elements.push(string_literal_owned(allocator, alias.clone())); - } - OutputExpression::LiteralArray(Box::new_in( - LiteralArrayExpr { entries: elements, source_span: None }, - &allocator, - )) -} - // ---- dependencies -------------------------------------------------------- /// Constructs a synthetic `TemplateDependency` per class `imports` names diff --git a/crates/oxc_angular_compiler/src/partial/directive.rs b/crates/oxc_angular_compiler/src/partial/directive.rs index e71a72bac..c052e208c 100644 --- a/crates/oxc_angular_compiler/src/partial/directive.rs +++ b/crates/oxc_angular_compiler/src/partial/directive.rs @@ -43,7 +43,7 @@ use super::factory::compile_declare_factory_function; use super::wrap_forward_ref; use crate::directive::{ QueryPredicate, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, - R3QueryMetadata, strip_host_key, + R3QueryMetadata, create_host_directive_mappings_array, strip_host_key, }; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, @@ -569,14 +569,14 @@ fn create_host_directives_array<'a>( if !hd.inputs.is_empty() { hd_entries.push(LiteralMapEntry::new( Ident::from("inputs"), - host_directives_mapping_array(allocator, &hd.inputs), + create_host_directive_mappings_array(allocator, &hd.inputs), false, )); } if !hd.outputs.is_empty() { hd_entries.push(LiteralMapEntry::new( Ident::from("outputs"), - host_directives_mapping_array(allocator, &hd.outputs), + create_host_directive_mappings_array(allocator, &hd.outputs), false, )); } @@ -591,24 +591,6 @@ fn create_host_directives_array<'a>( )) } -/// Mirrors upstream `createHostDirectivesMappingArray` — flat alternating -/// `[publicName, alias, publicName, alias, ...]` string array. -fn host_directives_mapping_array<'a>( - allocator: &'a Allocator, - pairs: &Vec<'a, (Ident<'a>, Ident<'a>)>, -) -> OutputExpression<'a> { - let mut elements: Vec<'a, OutputExpression<'a>> = - Vec::with_capacity_in(pairs.len() * 2, &allocator); - for (public_name, alias) in pairs { - elements.push(string_literal_owned(allocator, public_name.clone())); - elements.push(string_literal_owned(allocator, alias.clone())); - } - OutputExpression::LiteralArray(Box::new_in( - LiteralArrayExpr { entries: elements, source_span: None }, - &allocator, - )) -} - // ---- low-level helpers ---------------------------------------------------- fn clone_factory_deps<'a>( diff --git a/crates/oxc_angular_compiler/tests/dts_type_reference_test.rs b/crates/oxc_angular_compiler/tests/dts_type_reference_test.rs index 2174d59a2..37adf5e64 100644 --- a/crates/oxc_angular_compiler/tests/dts_type_reference_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_type_reference_test.rs @@ -132,7 +132,7 @@ export class HostCmp {} let (members, imports) = dts(&result, "HostCmp"); assert!( members.contains( - "[{ directive: typeof i1.HighlightDirective; inputs: { \"color\": \"color\" }; outputs: {}; }, \ + "[{ directive: typeof i1.HighlightDirective; inputs: { \"color\": \"color\"; }; outputs: {}; }, \ { directive: typeof LocalDir; inputs: {}; outputs: {}; }, \ { directive: typeof i2.default; inputs: {}; outputs: {}; }]" ), diff --git a/crates/oxc_angular_compiler/tests/host_directives_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/host_directives_ngtsc_test.rs new file mode 100644 index 000000000..e71780ba6 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/host_directives_ngtsc_test.rs @@ -0,0 +1,412 @@ +//! `hostDirectives` references and mappings, compared with ngtsc (#702). +//! +//! Expected text is what `@angular/compiler-cli` 22.2.4 writes for the same +//! sources (full and partial JS, and the `.d.ts`), compared with whitespace +//! removed. Two known differences: +//! - ngtsc writes `forwardRef(() => X)` in partial output, oxc writes +//! `forwardRef(function () { return X; })`. +//! - For a string export name (`export { X as "s-dir" }`) ngtsc writes +//! `i1.s-dir`, which does not parse; oxc writes `i1["s-dir"]`. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + CompilationMode, TransformOptions, TransformResult, transform_angular_file, +}; + +const MODES: [CompilationMode; 2] = [CompilationMode::Full, CompilationMode::Partial]; + +const HEADER: &str = "import { Component, Directive, forwardRef, Input, Output, EventEmitter } from '@angular/core';\n"; + +fn transform(source: &str, mode: CompilationMode) -> TransformResult { + let allocator = Allocator::default(); + let source = format!("{HEADER}{source}"); + let options = TransformOptions { compilation_mode: mode, ..Default::default() }; + let result = transform_angular_file(&allocator, "src/test.ts", &source, Some(&options), None); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + result +} + +fn squash(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[track_caller] +fn assert_has(code: &str, expected: &str) { + assert!(squash(code).contains(&squash(expected)), "expected `{expected}` in:\n{code}"); +} + +/// The `iN` → module pairs the JS imports as a namespace, in order. +fn js_namespace_imports(code: &str) -> Vec<(String, String)> { + code.lines() + .filter_map(|line| line.strip_prefix("import * as i")) + .filter_map(|rest| { + let (number, module) = rest.split_once(" from ")?; + if !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let alias = format!("i{number}"); + let module = module.trim_end_matches(';').trim_matches(['\'', '"']); + Some((alias.to_string(), module.to_string())) + }) + .collect() +} + +#[track_caller] +fn assert_imports(code: &str, expected: &[(&str, &str)]) { + let expected: Vec<(String, String)> = + expected.iter().map(|(a, m)| ((*a).to_string(), (*m).to_string())).collect(); + assert_eq!(js_namespace_imports(code), expected, "{code}"); +} + +/// The `.d.ts` members of `class_name`. +fn dts<'r>(result: &'r TransformResult, class_name: &str) -> &'r str { + &result + .dts_declarations + .iter() + .find(|decl| decl.class_name == class_name) + .unwrap_or_else(|| panic!("no .d.ts for {class_name}")) + .members +} + +/// The `.d.ts` namespace import of `alias` for `class_name`. +fn dts_import<'r>(result: &'r TransformResult, class_name: &str, alias: &str) -> &'r str { + result + .dts_declarations + .iter() + .find(|decl| decl.class_name == class_name) + .and_then(|decl| decl.namespace_imports.get(alias)) + .map_or("", String::as_str) +} + +// ---- input / output pairs --------------------------------------------------- + +#[test] +fn partial_writes_field_then_alias() { + let source = r"import { H } from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [{ directive: H, inputs: ['color: c'], outputs: ['changed: ch'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [{ directive: H, inputs: ['color: c'], outputs: ['changed: ch'] }] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_has( + &full.code, + r#"i0.ɵɵHostDirectivesFeature([{ directive: i1.H, inputs: ["color", "c"], outputs: ["changed", "ch"] }])"#, + ); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code) + .matches(&squash( + r#"hostDirectives: [{ directive: i1.H, inputs: ["color", "c"], outputs: ["changed", "ch"] }]"# + )) + .count(), + 2, + "{}", + partial.code + ); +} + +#[test] +fn partial_mapping_duplicates_and_several_pairs() { + let source = r"@Directive({ selector: '[l]' }) export class L { @Input() color: string; @Input() x: string; @Output() changed = new EventEmitter(); } +@Component({ selector: 'c', template: '', hostDirectives: [{ directive: L, inputs: ['color: c', 'x: y'], outputs: ['changed'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [{ directive: L, inputs: ['color: c', 'color'], outputs: ['changed: ch'] }] }) export class Dir {}"; + let partial = transform(source, CompilationMode::Partial); + assert_has( + &partial.code, + r#"hostDirectives: [{ directive: L, inputs: ["color", "c", "x", "y"], outputs: ["changed", "changed"] }]"#, + ); + assert_has( + &partial.code, + r#"hostDirectives: [{ directive: L, inputs: ["color", "color"], outputs: ["changed", "ch"] }]"#, + ); +} + +#[test] +fn dts_mapping_is_keyed_by_field() { + let source = r"import { H } from './dep'; +@Directive({ selector: '[l]' }) export class L {} +@Component({ selector: 'c', template: '', hostDirectives: [{ directive: H, inputs: ['color: c', 'x: y'], outputs: ['changed'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [{ directive: L, inputs: ['color: c', 'color'], outputs: ['changed: ch'] }] }) export class Dir {}"; + for mode in MODES { + let result = transform(source, mode); + assert!( + dts(&result, "Cmp").contains( + r#"[{ directive: typeof i1.H; inputs: { "color": "c"; "x": "y"; }; outputs: { "changed": "changed"; }; }]"# + ), + "{mode:?}: {}", + dts(&result, "Cmp") + ); + assert!( + dts(&result, "Dir").contains( + r#"[{ directive: typeof L; inputs: { "color": "color"; }; outputs: { "changed": "ch"; }; }]"# + ), + "{mode:?}: {}", + dts(&result, "Dir") + ); + } +} + +// ---- references by the module's export name ---------------------------------- + +#[test] +fn aliased_import_uses_export_name() { + let source = r"import { H as P } from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [P, { directive: P, inputs: ['color: c'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [P, { directive: P, inputs: ['color: c'] }] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code) + .matches(&squash( + r#"ɵɵHostDirectivesFeature([i1.H, { directive: i1.H, inputs: ["color", "c"] }])"# + )) + .count(), + 2, + "{}", + full.code + ); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code) + .matches(&squash(r#"hostDirectives: [{ directive: i1.H }, { directive: i1.H, inputs: ["color", "c"] }]"#)) + .count(), + 2, + "{}", + partial.code + ); + assert!(!squash(&full.code).contains("i1.P"), "{}", full.code); +} + +#[test] +fn default_import_uses_default_export() { + let source = r"import DD from './dep2'; +import { default as DE } from './dep2'; +@Component({ selector: 'c', template: '', hostDirectives: [DD, DE] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [DD, DE] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code).matches("ɵɵHostDirectivesFeature([i1.default,i1.default])").count(), + 2, + "{}", + full.code + ); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code) + .matches("hostDirectives:[{directive:i1.default},{directive:i1.default}]") + .count(), + 2, + "{}", + partial.code + ); +} + +#[test] +fn forward_ref_to_aliased_and_namespace_member() { + let source = r"import { H as P } from './dep'; +import * as dep from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [forwardRef(() => P), { directive: forwardRef(() => dep.H), outputs: ['changed: ch'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [forwardRef(() => P)] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_has( + &full.code, + r#"i0.ɵɵHostDirectivesFeature(function () { return [i1.H, { directive: i1.H, outputs: ["changed", "ch"] }]; })"#, + ); + assert_has(&full.code, "i0.ɵɵHostDirectivesFeature(function () { return [i1.H]; })"); + let result = transform(source, CompilationMode::Partial); + assert_has( + &result.code, + r#"hostDirectives: [{ directive: i0.forwardRef(function () { return i1.H; }) }, { directive: i0.forwardRef(function () { return i1.H; }), outputs: ["changed", "ch"] }]"#, + ); + assert!(dts(&result, "Cmp").contains( + r#"[{ directive: typeof i1.H; inputs: {}; outputs: {}; }, { directive: typeof i1.H; inputs: {}; outputs: { "changed": "ch"; }; }]"# + )); +} + +// ---- namespace members -------------------------------------------------------- + +#[test] +fn namespace_member() { + let source = r"import * as dep from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [dep.H, { directive: dep.H, inputs: ['color: c'] }] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [dep.H, { directive: dep.H, inputs: ['color: c'] }] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code) + .matches(&squash( + r#"ɵɵHostDirectivesFeature([i1.H, { directive: i1.H, inputs: ["color", "c"] }])"# + )) + .count(), + 2, + "{}", + full.code + ); + assert_imports(&full.code, &[("i0", "@angular/core"), ("i1", "./dep")]); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code) + .matches(&squash(r#"hostDirectives: [{ directive: i1.H }, { directive: i1.H, inputs: ["color", "c"] }]"#)) + .count(), + 2, + "{}", + partial.code + ); + for mode in MODES { + let result = transform(source, mode); + for class in ["Cmp", "Dir"] { + assert!( + dts(&result, class).contains( + r#"[{ directive: typeof i1.H; inputs: {}; outputs: {}; }, { directive: typeof i1.H; inputs: { "color": "c"; }; outputs: {}; }]"# + ), + "{mode:?} {class}: {}", + dts(&result, class) + ); + assert_eq!(dts_import(&result, class, "i1"), "./dep"); + } + } +} + +#[test] +fn namespace_member_in_constant_list() { + let source = r"import * as dep from './dep'; +import { H as P } from './dep'; +const HD = [dep.H, { directive: P, inputs: ['color: c'] }]; +@Component({ selector: 'c', template: '', hostDirectives: HD }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [...HD] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code) + .matches(&squash( + r#"ɵɵHostDirectivesFeature([i1.H, { directive: i1.H, inputs: ["color", "c"] }])"# + )) + .count(), + 2, + "{}", + full.code + ); + for class in ["Cmp", "Dir"] { + assert!(dts(&full, class).contains( + r#"[{ directive: typeof i1.H; inputs: {}; outputs: {}; }, { directive: typeof i1.H; inputs: { "color": "c"; }; outputs: {}; }]"# + )); + } +} + +/// The export name of `dep.H` is never looked up as a local name: here `H` +/// is also a named import from another module. +#[test] +fn namespace_member_named_like_another_import() { + let source = r"import * as dep from './dep'; +import { H } from './dep3'; +@Component({ selector: 'c', template: '', hostDirectives: [dep.H, H] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [dep.H, H] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code).matches("ɵɵHostDirectivesFeature([i1.H,i2.H])").count(), + 2, + "{}", + full.code + ); + assert_imports(&full.code, &[("i0", "@angular/core"), ("i1", "./dep"), ("i2", "./dep3")]); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code).matches("hostDirectives:[{directive:i1.H},{directive:i2.H}]").count(), + 2, + "{}", + partial.code + ); + for mode in MODES { + let result = transform(source, mode); + for class in ["Cmp", "Dir"] { + assert!( + dts(&result, class).contains( + "[{ directive: typeof i1.H; inputs: {}; outputs: {}; }, { directive: typeof i2.H; inputs: {}; outputs: {}; }]" + ), + "{mode:?} {class}: {}", + dts(&result, class) + ); + assert_eq!(dts_import(&result, class, "i1"), "./dep"); + assert_eq!(dts_import(&result, class, "i2"), "./dep3"); + } + } +} + +#[test] +fn namespace_member_named_like_a_local_class() { + let source = r"import * as dep from './dep'; +@Directive({ selector: '[hl]' }) export class H {} +@Component({ selector: 'c', template: '', hostDirectives: [dep.H, H] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [dep.H, H] }) export class Dir {}"; + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code).matches("ɵɵHostDirectivesFeature([i1.H,H])").count(), + 2, + "{}", + full.code + ); + for class in ["Cmp", "Dir"] { + assert!(dts(&full, class).contains( + "[{ directive: typeof i1.H; inputs: {}; outputs: {}; }, { directive: typeof H; inputs: {}; outputs: {}; }]" + )); + } +} + +// ---- string export names ------------------------------------------------------ + +#[test] +fn string_export_name() { + for source in [ + r#"import { "s-dir" as SDir } from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [SDir] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [SDir] }) export class Dir {}"#, + r#"import * as dep from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [dep["s-dir"]] }) export class Cmp {} +@Directive({ selector: '[d]', hostDirectives: [dep["s-dir"]] }) export class Dir {}"#, + ] { + let full = transform(source, CompilationMode::Full); + assert_eq!( + squash(&full.code).matches(r#"ɵɵHostDirectivesFeature([i1["s-dir"]])"#).count(), + 2, + "{}", + full.code + ); + let partial = transform(source, CompilationMode::Partial); + assert_eq!( + squash(&partial.code).matches(r#"hostDirectives:[{directive:i1["s-dir"]}]"#).count(), + 2, + "{}", + partial.code + ); + for class in ["Cmp", "Dir"] { + assert!( + dts(&full, class) + .contains(r#"[{ directive: typeof i1["s-dir"]; inputs: {}; outputs: {}; }]"#), + "{class}: {}", + dts(&full, class) + ); + } + } +} + +// ---- namespace numbering -------------------------------------------------------- + +/// ngtsc emits `ɵfac` before `ɵcmp`, so a constructor dependency's module +/// takes `i1` before the host directive's. +#[test] +fn partial_numbers_factory_deps_first() { + for source in [ + r"import { H as P } from './dep'; +import { Svc } from './svc'; +@Component({ selector: 'c', template: '', hostDirectives: [P] }) export class Cmp { constructor(s: Svc) {} }", + r"import { Svc } from './svc'; +import * as dep from './dep'; +@Component({ selector: 'c', template: '', hostDirectives: [dep.H] }) export class Cmp { constructor(s: Svc) {} }", + ] { + for mode in MODES { + let result = transform(source, mode); + assert_imports( + &result.code, + &[("i0", "@angular/core"), ("i1", "./svc"), ("i2", "./dep")], + ); + } + let partial = transform(source, CompilationMode::Partial); + assert_has(&partial.code, "deps: [{ token: i1.Svc }]"); + assert_has(&partial.code, "hostDirectives: [{ directive: i2.H }]"); + } +} diff --git a/napi/angular-compiler/test/host-directives-runtime.test.ts b/napi/angular-compiler/test/host-directives-runtime.test.ts new file mode 100644 index 000000000..aa21fba10 --- /dev/null +++ b/napi/angular-compiler/test/host-directives-runtime.test.ts @@ -0,0 +1,193 @@ +/** + * `hostDirectives` at runtime (#702). + * + * - A partial declaration linked by either linker binds the host + * directive's own fields to the aliases written in `inputs` / `outputs`, + * and a host directive declared after the class (`forwardRef`) is read + * once the classes are defined. + * - A full compile reaches an imported host directive by the module's export + * name, also through an alias, a namespace import or a string export name. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { linkAngularPackageSync, transformAngularFileSync } from '../index.js' + +const TMP = join(fileURLToPath(new URL('.', import.meta.url)), '.host-directives-runtime-tmp') +let nextCase = 0 + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }) +}) + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }) +}) + +/** Angular's linker, as the Angular CLI runs it on libraries. */ +async function angularLink(code: string, filename: string): Promise { + const require = createRequire(import.meta.url) + const babel = createRequire(require.resolve('@angular/compiler-cli/package.json'))('@babel/core') + const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel') + const plugin = createEs2015LinkerPlugin({ + linkerJitMode: false, + fileSystem: { + resolve, + exists: existsSync, + dirname, + relative, + readFile: (file: string) => readFileSync(file, 'utf8'), + } as any, + logger: { level: 1, debug() {}, info() {}, warn() {}, error() {} }, + }) + return babel.transformSync(code, { + filename, + babelrc: false, + configFile: false, + plugins: [plugin], + }).code +} + +const LINKERS = { + angular: angularLink, + oxc: async (code: string, filename: string) => linkAngularPackageSync(code, filename).code, +} + +/** + * Writes `files` (`.mts` sources, importing each other as `./name.mjs`): + * `lib` ones partially compiled and linked with `linker`, the others fully + * compiled. Returns the directory. + */ +async function build(files: Record, linker: keyof typeof LINKERS): Promise { + const dir = join(TMP, `case-${nextCase++}`) + mkdirSync(dir, { recursive: true }) + for (const [name, source] of Object.entries(files)) { + const file = join(dir, `${name}.mts`) + const partial = name.startsWith('lib') + const result = transformAngularFileSync(source, file, { + compilationMode: partial ? 'partial' : 'full', + }) + expect(result.errors).toEqual([]) + const out = join(dir, `${name}.mjs`) + writeFileSync(out, partial ? await LINKERS[linker](result.code, out) : result.code) + } + return dir +} + +/** + * Loads `name` in plain Node and prints, for each exported class listed, its + * host directives as `[directive name, inputs, outputs]`, resolving the + * `forwardRef` list. + */ +function hostDirectives(dir: string, name: string, classes: string[]): unknown { + const script = ` + globalThis.ngJitMode = false; + globalThis.ngDevMode = false; + const { ɵgetComponentDef: cmp, ɵgetDirectiveDef: dir } = await import('@angular/core'); + const m = await import(${JSON.stringify(pathToFileURL(join(dir, `${name}.mjs`)).href)}); + // A mapping is an object once the runtime has read it, and still the + // flat [field, alias, ...] array inside a forwardRef list. + const map = (v) => Array.isArray(v) + ? Object.fromEntries(v.flatMap((x, i) => (i % 2 ? [] : [[x, v[i + 1]]]))) + : (v ?? {}); + const out = {}; + for (const name of ${JSON.stringify(classes)}) { + const def = cmp(m[name]) || dir(m[name]); + out[name] = def.hostDirectives.map((h) => { + const entries = typeof h === 'function' ? h() : [h]; + return entries.map((e) => typeof e === 'function' + ? [e.name, {}, {}] + : [e.directive.name, map(e.inputs), map(e.outputs)]); + }).flat(); + } + console.log(JSON.stringify(out)); + ` + return JSON.parse( + execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + encoding: 'utf8', + }), + ) +} + +describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', (linker) => { + it('binds the host directive fields to their aliases', async () => { + const dir = await build( + { + lib: ` + import { Component, Directive, EventEmitter, Input, Output } from '@angular/core'; + @Directive({ selector: '[l]' }) + export class L { @Input() color = ''; @Output() changed = new EventEmitter(); } + @Component({ selector: 'c', template: '', + hostDirectives: [{ directive: L, inputs: ['color: c'], outputs: ['changed: ch'] }] }) + export class Cmp {} + @Directive({ selector: '[d]', hostDirectives: [{ directive: L, inputs: ['color: c'] }] }) + export class Dir {} + `, + }, + linker, + ) + expect(hostDirectives(dir, 'lib', ['Cmp', 'Dir'])).toEqual({ + Cmp: [['L', { color: 'c' }, { changed: 'ch' }]], + Dir: [['L', { color: 'c' }, {}]], + }) + }) + + it('reads a host directive declared after the class', async () => { + const dir = await build( + { + lib: ` + import { Component, Directive, forwardRef, Input } from '@angular/core'; + @Component({ selector: 'c', template: '', hostDirectives: [forwardRef(() => L)] }) + export class Cmp {} + @Directive({ selector: '[d]', + hostDirectives: [{ directive: forwardRef(() => L), inputs: ['color: c'] }] }) + export class Dir {} + @Directive({ selector: '[l]' }) export class L { @Input() color = ''; } + `, + }, + linker, + ) + expect(hostDirectives(dir, 'lib', ['Cmp', 'Dir'])).toEqual({ + Cmp: [['L', {}, {}]], + Dir: [['L', { color: 'c' }, {}]], + }) + }) +}) + +describe('full compile', () => { + it('reaches an imported host directive by its export name', async () => { + const dir = await build( + { + dep: ` + import { Directive } from '@angular/core'; + @Directive({ selector: '[h]' }) export class H {} + @Directive({ selector: '[s]' }) class SDirImpl {} + export { SDirImpl as "s-dir" }; + `, + main: ` + import { Component, Directive } from '@angular/core'; + import { H as P, "s-dir" as SDir } from './dep.mjs'; + import * as dep from './dep.mjs'; + @Component({ selector: 'c', template: '', hostDirectives: [P, dep.H, SDir, dep["s-dir"]] }) + export class Cmp {} + @Directive({ selector: '[d]', hostDirectives: [P, dep.H, SDir, dep["s-dir"]] }) + export class Dir {} + `, + }, + 'oxc', + ) + const expected = [ + ['H', {}, {}], + ['H', {}, {}], + ['SDirImpl', {}, {}], + ['SDirImpl', {}, {}], + ] + expect(hostDirectives(dir, 'main', ['Cmp', 'Dir'])).toEqual({ Cmp: expected, Dir: expected }) + }) +}) From e1694904c2ba0353cab4e3b393f226a4dcaf7a8a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:06:39 +0800 Subject: [PATCH 07/22] fix(ng-module): read forwardRef targets, namespace members and const lists like ngtsc (#703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With cross-file analysis, an NgModule list entry that ngtsc reads statically is now read too, so the partial declaration, the full-mode scope and the .d.ts list the classes instead of dropping the entry: - forwardRef(fn) is read like ngtsc's expandForwardRef: an arrow or function expression (parentheses and `as` around it looked through), with an expression body or one `return`. Its target is read like any entry, so `forwardRef(() => dep.X)`, `forwardRef(() => LIST)` and `forwardRef(() => IMPORTED_LIST)` give their classes. An aliased import of forwardRef from @angular/core counts too. - `dep['X']` is read like `dep.X`. - A top-level `let` or `var` list of this file is read, like a `const`. - A package's `declare const X: [typeof A, typeof B]` is read by its tuple type (also nested, and spread), instead of being taken for a class named X. - A const list from another file is named through the files of its classes, so only those files decide whether the lists stay lazy. A file that only holds the list and loads this one back no longer adds a wrap (ngtsc writes none either). The full-mode setNgModuleScope now lists what was read for each list whose entries were all read, like ngtsc. A list with an entry Oxc can't read stays as written (the runtime flattens it) and takes no namespace. The scope takes its namespace numbers after the factory and before ɵinj, as in ngtsc. One difference from ngtsc stays on purpose: when the file of a listed class loads this one back, the lists stay lazy (`() => [...]`). ngtsc's eager list throws when that file is loaded first, and Oxc runs the scope call with no ngJitMode guard. Without cross-file analysis nothing new is guessed: only lists made of classes of this file change. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 34 +- .../src/component/transform.rs | 67 +++- .../src/ng_module/decorator.rs | 14 +- .../src/ng_module/definition.rs | 44 +-- .../src/ng_module/module_with_providers.rs | 162 ++++++--- .../tests/ng_module_injector_imports_test.rs | 14 +- .../tests/ng_module_list_reading_test.rs | 330 ++++++++++++++++++ .../tests/partial_ng_module_mwp_test.rs | 186 +++++++--- 8 files changed, 703 insertions(+), 148 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs index 5c735ffa5..7d81e59b8 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs @@ -104,7 +104,6 @@ enum Declared { /// A `const` list of references, spread or named in a module's lists. List(Vec), /// A `declare const X: readonly [typeof A, typeof B]` list, by its type. - /// Only `ɵinj` reads it. TypeList(Vec), } @@ -451,20 +450,24 @@ impl CrossFileAnalyzer { if let Some(method) = question.method { return ng_module(inner.mwp_method_module(&holder, method)?); } + // A list, flattened. Its classes are named through the files that + // declare them, never through the binding, so only their files + // count for `unready` (ngtsc writes no wrap for them either). + let list = |items: &[RawRef]| { + let mut classes = Vec::new(); + let mut forward_ref = false; + inner.flatten_list(&holder, items, &mut classes, &mut forward_ref, 0)?; + let unready = classes.iter().any(unready); + let classes = classes.iter().map(named).collect::>()?; + Some(Answer::Classes { classes, unready, forward_ref }) + }; match inner.declared(&holder.decl).as_deref() { // A spread must be of an array (`read_declared` also gives a - // list for `const X = A` or `const X = A.forRoot()`). - Some(Declared::List(items)) if inner.is_const_array(&holder) => { - let mut classes = Vec::new(); - let mut forward_ref = false; - inner.flatten_list(&holder, items, &mut classes, &mut forward_ref, 0)?; - // Full compilation lists the binding as written, so the file - // it is imported from must not load this one back either. - let unready = classes.iter().any(unready) - || inner.binding_loads_back(&file, question.namespace.unwrap_or(question.name)); - let classes = classes.iter().map(named).collect::>()?; - Some(Answer::Classes { classes, unready, forward_ref }) - } + // list for `const X = A` or `const X = A.forRoot()`). A + // declaration file's `declare const X: [typeof A, typeof B]` is + // read by its type, like ngtsc's evaluator does. + Some(Declared::List(items)) if inner.is_const_array(&holder) => list(items), + Some(Declared::TypeList(items)) => list(items), _ if question.spread => None, // A constant holding a `ModuleWithProviders`. Some(Declared::List(_)) | None => ng_module(inner.mwp_const_module(&holder)?), @@ -823,14 +826,15 @@ impl Inner { let read = items.iter().try_for_each(|raw| { let class = self.resolve_ref(raw, list.owner.as_deref(), 0)?; match &*self.declared(&class.decl)? { - Declared::List(items) if raw.method.is_none() => { + // A declaration file writes a list as a tuple type, which + // ngtsc's evaluator reads too. + Declared::List(items) | Declared::TypeList(items) if raw.method.is_none() => { self.flatten_list(&class, items, out, forward_ref, depth + 1) } Declared::NgModule { .. } => { out.push(class); Some(()) } - // A declaration file's list type is not read here. Declared::TypeList(_) => None, _ if raw.method.is_some() => None, _ => { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b9b2ba12a..b911963d4 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4875,11 +4875,11 @@ pub fn transform_angular_file( ); } - // Read the lists like ngtsc: in partial mode the - // declaration lists what they give, and in both modes - // they are wrapped when one names a class declared - // later. A class in a function may see local bindings: - // it stays as before. + // Read the lists like ngtsc: the partial declaration + // and the full-mode scope list what they give, and in + // both modes they are wrapped when one names a class + // declared later. A class in a function may see local + // bindings: it stays as before. let module_lists = if site.nested { None } else { @@ -4896,8 +4896,8 @@ pub fn transform_angular_file( .as_ref() .filter(|_| options.resolve_imported_values) .map(|analyzer| (analyzer, path, source)), - matches!(options.compilation_mode, crate::CompilationMode::Partial) - .then_some(&mut file_namespace_registry), + &mut file_namespace_registry, + matches!(options.compilation_mode, crate::CompilationMode::Partial), ) }; @@ -5737,12 +5737,14 @@ fn scope_dependencies<'a>( /// Read the `declarations`, `imports` and `exports` of the NgModule /// `class` like ngtsc (see `ng_module::module_with_providers`), and set -/// whether its lists are wrapped in `() => [...]`. With a `registry` -/// (partial mode), the declaration lists what they give. With cross-file +/// whether its lists are wrapped in `() => [...]`. In `partial` mode, the +/// declaration lists what they give. In full mode, so does +/// `ɵɵsetNgModuleScope`, for each list whose entries were all read; any +/// other list stays as written (the runtime flattens it). With cross-file /// analysis, a name of another file is read too, and a class found there -/// is named through a namespace import, like `ɵɵsetComponentScope` names -/// classes. Anything unread keeps its old output. Gives the lists read, -/// for the `.d.ts`. +/// is named through a namespace import of `registry`, like +/// `ɵɵsetComponentScope` names classes. Anything unread keeps its old +/// output. Gives the lists read, for the `.d.ts`. #[expect(clippy::too_many_arguments)] fn resolve_ng_module_lists<'a>( allocator: &'a Allocator, @@ -5753,7 +5755,8 @@ fn resolve_ng_module_lists<'a>( import_map: &ImportMap<'_>, import_elision: &ImportElisionAnalyzer<'_>, #[cfg(feature = "cross_file_elision")] analyzer: Option<(&CrossFileAnalyzer, &str, &str)>, - registry: Option<&mut NamespaceRegistry<'a>>, + registry: &mut NamespaceRegistry<'a>, + partial: bool, ) -> Option { #[cfg(feature = "cross_file_elision")] use crate::ng_module::module_with_providers::{Answer, Question, ResolveImported}; @@ -5769,6 +5772,12 @@ fn resolve_ng_module_lists<'a>( let binding = |name: &str| match import_map.get(&Ident::from(name)) { Some(info) if info.is_type_only => Binding::Other, Some(info) if !info.is_named_import => Binding::Namespace, + Some(info) + if info.source_module == "@angular/core" + && info.imported_name.as_ref().is_some_and(|name| name == "forwardRef") => + { + Binding::ForwardRef + } Some(_) => Binding::Import { kept: !import_elision.should_elide(name) }, None => Binding::Other, }; @@ -5798,14 +5807,34 @@ fn resolve_ng_module_lists<'a>( resolve_imported, )?; metadata.contains_forward_decls = lists.wrap; - let Some(registry) = registry else { return Some(lists) }; - // In the order the declaration lists them, so namespaces are numbered - // like ngtsc's. + // In the order ngtsc lists them (declarations, imports, exports), so + // namespaces are numbered like ngtsc's. In full mode this comes after + // the factory and before ɵinj, as in ngtsc. let mut namespace_of = |specifier: &str| registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))); - metadata.partial_declarations = lists.emit(allocator, ListKey::Declarations, &mut namespace_of); - metadata.partial_imports = lists.emit(allocator, ListKey::Imports, &mut namespace_of); - metadata.partial_exports = lists.emit(allocator, ListKey::Exports, &mut namespace_of); + let mut emit = |key: ListKey| { + // A full-mode list with an unread entry stays as written, and takes + // no namespace. + if partial || lists.is_complete(key) { + lists.emit(allocator, key, &mut namespace_of) + } else { + None + } + }; + metadata.partial_declarations = emit(ListKey::Declarations); + metadata.partial_imports = emit(ListKey::Imports); + metadata.partial_exports = emit(ListKey::Exports); + if !partial { + if metadata.partial_declarations.is_some() { + metadata.declarations_scope_expr = None; + } + if metadata.partial_imports.is_some() { + metadata.imports_scope_expr = None; + } + if metadata.partial_exports.is_some() { + metadata.exports_scope_expr = None; + } + } Some(lists) } diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index 0d04911a3..f544f9fc7 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -50,18 +50,20 @@ pub struct NgModuleMetadata<'a> { /// The `declarations`, `imports` and `exports` expressions as written, /// kept only for a list that is not a plain array of class references /// (a const array, a spread, a `forRoot()` call...), whose names above - /// are incomplete. `ɵɵsetNgModuleScope` emits these instead. + /// are incomplete. `ɵɵsetNgModuleScope` emits these instead, unless the + /// transform read every entry (it clears them then). pub declarations_scope_expr: Option>, /// See [`Self::declarations_scope_expr`]. pub imports_scope_expr: Option>, /// See [`Self::declarations_scope_expr`]. pub exports_scope_expr: Option>, - /// The partial `ɵɵngDeclareNgModule` declarations, read like ngtsc - /// (lists flattened, each `ModuleWithProviders` as its NgModule), set - /// by the transform when it could read an entry (see - /// `module_with_providers`). `None` lists [`Self::declarations`]. Full - /// mode never reads it. + /// The lists read like ngtsc (flattened, each `ModuleWithProviders` as + /// its NgModule), set by the transform (see `module_with_providers`): + /// in partial mode when it could read an entry, in full mode when it + /// read them all. The partial `ɵɵngDeclareNgModule` and the full-mode + /// `ɵɵsetNgModuleScope` list them. `None` lists + /// [`Self::declarations`]. pub partial_declarations: Option>>, /// See [`Self::partial_declarations`]; `None` lists [`Self::imports`]. pub partial_imports: Option>>, diff --git a/crates/oxc_angular_compiler/src/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index a7ea162cf..846c9ebbc 100644 --- a/crates/oxc_angular_compiler/src/ng_module/definition.rs +++ b/crates/oxc_angular_compiler/src/ng_module/definition.rs @@ -199,6 +199,28 @@ pub fn generate_full_ng_module_definition<'a>( ) -> Option> { let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let mut r3_metadata = metadata.to_r3_metadata(allocator)?; + // Like ngtsc's `resolveTypeList`, the partial declaration and the + // full-mode scope list what the transform read: lists flattened, and + // the NgModule of each `ModuleWithProviders`. + { + let resolved = |list: &OxcVec<'a, OutputExpression<'a>>| { + OxcVec::from_iter_in( + list.iter().map(|item| { + crate::ng_module::R3Reference::value_only(item.clone_in(allocator)) + }), + &allocator, + ) + }; + if let Some(list) = &metadata.partial_declarations { + r3_metadata.declarations = resolved(list); + } + if let Some(list) = &metadata.partial_imports { + r3_metadata.imports = resolved(list); + } + if let Some(list) = &metadata.partial_exports { + r3_metadata.exports = resolved(list); + } + } // IMPORTANT: Generate ɵfac BEFORE ɵmod and ɵinj to match Angular's namespace index assignment order. // Angular processes results in order [fac, mod, inj, ...] during the transform phase @@ -223,28 +245,6 @@ pub fn generate_full_ng_module_definition<'a>( // statement upstream-mandated; the linker re-creates remote // scoping at link time if needed. See // `compiler-cli/src/ngtsc/annotations/ng_module/src/handler.ts:971`. - // - // Like ngtsc's `resolveTypeList`, the declaration lists what - // the transform read: lists flattened, and the NgModule of each - // `ModuleWithProviders`. Only here: the full-mode scope keeps - // the raw lists. - let resolved = |list: &OxcVec<'a, OutputExpression<'a>>| { - OxcVec::from_iter_in( - list.iter().map(|item| { - crate::ng_module::R3Reference::value_only(item.clone_in(allocator)) - }), - &allocator, - ) - }; - if let Some(list) = &metadata.partial_declarations { - r3_metadata.declarations = resolved(list); - } - if let Some(list) = &metadata.partial_imports { - r3_metadata.imports = resolved(list); - } - if let Some(list) = &metadata.partial_exports { - r3_metadata.exports = resolved(list); - } let fac_definition = compile_declare_factory_for_ng_module( allocator, core_namespace, diff --git a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs index 32ce209f7..3ae6afbce 100644 --- a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs +++ b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs @@ -16,11 +16,13 @@ //! `ɵɵdefineNgModule`, the runtime gets a class that is no module and a //! `ModuleWithProviders` object. //! -//! Only the partial declaration lists change: the injector keeps the raw -//! entries (it needs their providers), and the full-mode -//! `ɵɵsetNgModuleScope` keeps the raw list, which the runtime unwraps. In -//! both modes, the lists are wrapped in `() => [...]` like ngtsc does: when -//! one names a class this file declares after the NgModule. +//! The partial declaration lists what is read, and so does the full-mode +//! `ɵɵsetNgModuleScope` for a list whose entries were all read (ngtsc +//! lists the classes there too). A full-mode list with an entry Oxc can't +//! read stays as written: the runtime flattens it. The injector keeps the +//! raw entries (it needs their providers). In both modes, the lists are +//! wrapped in `() => [...]` like ngtsc does: when one names a class this +//! file declares after the NgModule. //! //! A top-level entry that can't be read keeps its old output. This file //! reads what one file can tell. With cross-file analysis, a name of @@ -278,13 +280,34 @@ pub fn top_level_const_init<'b, 'a>( program: &'b Program<'a>, name: &str, ) -> Option<&'b Expression<'a>> { + top_level_init(program, name, false) +} + +/// The initializer of the top-level `const`, `let` or `var` `name` of +/// `program`. ngtsc's evaluator reads the initializer of any variable +/// (`getVariableValue`). +fn top_level_var_init<'b, 'a>(program: &'b Program<'a>, name: &str) -> Option<&'b Expression<'a>> { + top_level_init(program, name, true) +} + +fn top_level_init<'b, 'a>( + program: &'b Program<'a>, + name: &str, + any_kind: bool, +) -> Option<&'b Expression<'a>> { + use oxc_ast::ast::VariableDeclarationKind; program.body.iter().find_map(|stmt| { let declaration = match stmt { Statement::ExportDeclaration(decl) => &decl.declaration, _ => stmt.as_declaration()?, }; let Declaration::VariableDeclaration(vars) = declaration else { return None }; - if !vars.kind.is_const() { + let read = match vars.kind { + VariableDeclarationKind::Const => true, + VariableDeclarationKind::Let | VariableDeclarationKind::Var => any_kind, + _ => false, + }; + if !read { return None; } vars.declarations @@ -360,6 +383,9 @@ pub enum Binding { Import { kept: bool }, /// `import * as ns`. Namespace, + /// `forwardRef` of `@angular/core` imported under another name + /// (`import { forwardRef as fr }`). + ForwardRef, /// Anything else: a declaration, a type-only import, nothing. Other, } @@ -543,6 +569,11 @@ impl ModuleLists { Some(list) } + /// Whether every entry of the `key` list was read. + pub fn is_complete(&self, key: ListKey) -> bool { + self.lists[key as usize].iter().all(|entry| entry.refs.is_some()) + } + /// The `.d.ts` type reference of each class of the `key` list, read /// like `emit`. `type_ref(None, name)` maps a name of this file, and /// `type_ref(Some(specifier), name)` an export of a module. An unread @@ -596,6 +627,56 @@ fn fallback_name(element: &ArrayExpressionElement<'_>) -> Option { Some(name.to_string()) } +/// What `forwardRef(fn)` reads, like ngtsc's `expandForwardRef`: `fn` +/// (parentheses and `as` around it looked through) is an arrow function or +/// a function expression, whose body is an expression or a block holding +/// one `return expr;`. `None` otherwise, or with more than one argument. +fn forward_ref_target<'b, 'a>( + call: &'b oxc_ast::ast::CallExpression<'a>, +) -> Option<&'b Expression<'a>> { + let [argument] = call.arguments.as_slice() else { return None }; + let mut function = argument.as_expression()?; + loop { + function = match function { + Expression::ParenthesizedExpression(e) => &e.expression, + Expression::TSAsExpression(e) => &e.expression, + _ => break, + }; + } + let body = match function { + Expression::ArrowFunctionExpression(arrow) => match arrow.get_expression() { + Some(expr) => return Some(expr), + None => arrow.get_function_body()?, + }, + Expression::FunctionExpression(function) => function.body.as_ref()?, + _ => return None, + }; + // A directive is one more statement to TypeScript. + if !body.directives.is_empty() { + return None; + } + let [Statement::ReturnStatement(ret)] = body.statements.as_slice() else { return None }; + ret.argument.as_ref() +} + +/// `(object, name)` of `object.name`, or of `object['name']`, which +/// ngtsc's evaluator reads the same way. +fn member_access<'b, 'a>(expr: &'b Expression<'a>) -> Option<(&'b Expression<'a>, &'b str)> { + match expr { + Expression::StaticMemberExpression(member) => { + Some((&member.object, member.property.name.as_str())) + } + Expression::ComputedMemberExpression(member) => match evaluated(&member.expression) { + Expression::StringLiteral(key) => Some((&member.object, key.value.as_str()?)), + Expression::TemplateLiteral(key) if key.expressions.is_empty() => { + Some((&member.object, key.quasis.first()?.value.cooked.as_ref()?.as_str()?)) + } + _ => None, + }, + _ => None, + } +} + /// Reads list entries in the compiled file. struct Walker<'p, 'a, 'r, B> { program: &'p Program<'a>, @@ -665,18 +746,18 @@ impl Binding> Walker<'_, '_, '_, B> { } match evaluated(expr) { Expression::ArrayExpression(array) => self.array(array, depth), - Expression::Identifier(id) => match top_level_const_init(self.program, &id.name) { + Expression::Identifier(id) => match top_level_var_init(self.program, &id.name) { Some(init) => match evaluated(init) { Expression::ArrayExpression(array) => self.array(array, depth + 1), _ => None, }, None => self.ask_import(&id.name, true), }, - Expression::StaticMemberExpression(member) => { - let Expression::Identifier(ns) = evaluated(&member.object) else { return None }; - self.ask_namespace(&ns.name, &member.property.name, None, true) + expr => { + let (object, name) = member_access(expr)?; + let Expression::Identifier(ns) = evaluated(object) else { return None }; + self.ask_namespace(&ns.name, name, None, true) } - _ => None, } } @@ -687,12 +768,13 @@ impl Binding> Walker<'_, '_, '_, B> { match evaluated(expr) { Expression::ArrayExpression(array) => self.array(array, depth), Expression::Identifier(id) => self.name(&id.name, depth), - Expression::StaticMemberExpression(member) => { - let Expression::Identifier(ns) = evaluated(&member.object) else { return None }; - self.ask_namespace(&ns.name, &member.property.name, None, false) - } Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(id) if id.name == "forwardRef" => self.forward_ref(call), + Expression::Identifier(id) + if id.name == "forwardRef" + || matches!((self.binding)(&id.name), Binding::ForwardRef) => + { + self.forward_ref(call, depth) + } _ => { let (namespace, class, method) = static_call(call)?; self.mwp_call(namespace, class, method) @@ -702,7 +784,11 @@ impl Binding> Walker<'_, '_, '_, B> { ObjectModule::Value(Expression::Identifier(id)) => self.class_name(&id.name), _ => None, }, - _ => None, + expr => { + let (object, name) = member_access(expr)?; + let Expression::Identifier(ns) = evaluated(object) else { return None }; + self.ask_namespace(&ns.name, name, None, false) + } } } @@ -715,7 +801,7 @@ impl Binding> Walker<'_, '_, '_, B> { if self.local_class(name) { return Some(vec![Ref::Local(name.into())]); } - let Some(init) = top_level_const_init(self.program, name) else { + let Some(init) = top_level_var_init(self.program, name) else { return self.ask_import(name, false); }; match evaluated(init) { @@ -756,40 +842,26 @@ impl Binding> Walker<'_, '_, '_, B> { } } - /// `forwardRef(() => name)`. The wrap stays unless the target is known - /// to be initialized when the NgModule is defined. + /// `forwardRef(fn)`: what `fn` returns, read like any entry (ngtsc's + /// `createForwardRefResolver`). ngtsc wraps the lists only for a class + /// of this file declared after the NgModule. Oxc also keeps the wrap + /// when the target is unread, or may not be defined yet as the NgModule + /// is (its file loads this one back): the full-mode scope runs with no + /// `ngJitMode` guard, so reading it early would throw. fn forward_ref( &mut self, call: &oxc_ast::ast::CallExpression<'_>, + depth: u8, ) -> Option> { - let target = match call.arguments.first() { - Some(oxc_ast::ast::Argument::ArrowFunctionExpression(arrow)) => { - arrow.get_expression().map(evaluated) - } - _ => None, - }; - let Some(Expression::Identifier(id)) = target else { - self.wrap = true; - return None; - }; - let name = id.name.as_str(); - if self.local_class(name) { - return Some(vec![Ref::Local(name.into())]); - } - let Binding::Import { kept } = (self.binding)(name) else { + let Some(target) = forward_ref_target(call) else { self.wrap = true; return None; }; - let question = Question { namespace: None, name, method: None, spread: false }; - match self.ask(&question) { - Some(Answer::AsWritten { unready }) => self.wrap |= unready, - Some(Answer::Classes { .. }) => { - self.wrap = true; - return None; - } - None => self.wrap = true, - } - kept.then(|| vec![Ref::Local(name.into())]) + let unsure = std::mem::replace(&mut self.unsure, false); + let refs = self.value(target, depth + 1); + self.wrap |= refs.is_none() || self.unsure; + self.unsure |= unsure; + refs } /// A name this file imports. diff --git a/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs index 68f5748ac..fe675fd8f 100644 --- a/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs @@ -1175,9 +1175,19 @@ export class AModule {} assert!(code.contains("import * as i1 from './dep';"), "{code}"); assert!(code.contains("import * as i2 from 'fakelib';"), "{code}"); } - // The JIT scope keeps the lists as written. + // Like ngtsc, the JIT scope lists the classes too, the package's + // tuple type read as a list: ngtsc full gives + // `ɵɵsetNgModuleScope(SpreadModule, { imports: [i1.Dir, i1.ProvModule] })` + // and `ɵɵsetNgModuleScope(LibListModule, { imports: [i2.LibDir, i2.LibModule] })`. let full: String = code[0].chars().filter(|c| !c.is_whitespace()).collect(); - assert!(full.contains("ɵɵsetNgModuleScope(SpreadModule,{imports:[...LIST]})"), "{full}"); + assert!( + full.contains("ɵɵsetNgModuleScope(SpreadModule,{imports:[i1.Dir,i1.ProvModule]})"), + "{full}" + ); + assert!( + full.contains("ɵɵsetNgModuleScope(LibListModule,{imports:[i2.LibDir,i2.LibModule]})"), + "{full}" + ); } #[test] diff --git a/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs new file mode 100644 index 000000000..bda72d687 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs @@ -0,0 +1,330 @@ +//! NgModule lists ngtsc reads statically and Oxc used to keep as written +//! (#703): `forwardRef` targets other than a plain name, namespace +//! members, `const` lists behind a `forwardRef`, and a package's +//! `declare const` tuple. With cross-file analysis (what the Vite plugin +//! uses), the partial `ɵɵngDeclareNgModule`, the full-mode +//! `ɵɵsetNgModuleScope` and the `.d.ts` list the classes like ngtsc. +//! +//! Each expected string is what `@angular/compiler-cli` 22.2.4 writes for +//! the same files. The injector keeps its entries as written. + +#![cfg(feature = "cross_file_elision")] + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{CompilationMode, TransformOptions, transform_angular_file}; +use tempfile::TempDir; + +const DEP: &str = r" +import { Directive, NgModule, ModuleWithProviders } from '@angular/core'; +@Directive({ selector: '[h]', standalone: false }) export class H {} +@NgModule({}) export class DepModule { static forRoot() { return { ngModule: DepModule, providers: [] }; } } +@NgModule({}) export class TypedModule { static forRoot(x?: any): ModuleWithProviders { if (x) {} return { ngModule: TypedModule, providers: [] }; } } +export class Holder { static forRoot(): ModuleWithProviders { const r = { ngModule: DepModule }; return r; } } +@NgModule({}) export class OtherModule {} +export const DEP_LIST = [DepModule, OtherModule]; +export const MWP = DepModule.forRoot(); +"; + +const DEP2: &str = r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { DepModule } from './dep'; +@NgModule({}) export class Dep2Module { static forRoot(): ModuleWithProviders { return { ngModule: Dep2Module }; } } +export const LIST2 = [DepModule, Dep2Module]; +"; + +const FAKELIB: &str = r" +import * as i0 from '@angular/core'; +import { ModuleWithProviders } from '@angular/core'; +export declare class LibModule { + static forRoot(cfg?: any): ModuleWithProviders; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +export declare class LibTwoModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +export declare const LIB_ALL: [typeof LibModule, typeof LibTwoModule]; +"; + +fn write(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, content).unwrap(); + path +} + +fn project() -> TempDir { + let dir = TempDir::new().unwrap(); + write(&dir, "src/dep.ts", DEP); + write(&dir, "src/dep2.ts", DEP2); + write( + &dir, + "node_modules/fakelib/package.json", + r#"{ "name": "fakelib", "types": "index.d.ts" }"#, + ); + write(&dir, "node_modules/fakelib/index.d.ts", FAKELIB); + dir +} + +fn compact(code: &str) -> String { + code.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// The full and partial output of `src/m.ts`, and the `.d.ts` members of +/// its class `M`, all without whitespace. +struct Out { + full: String, + partial: String, + dts: String, +} + +fn compile(source: &str) -> Out { + let dir = project(); + let path = write(&dir, "src/m.ts", source); + let run = |mode| { + let allocator = Allocator::default(); + let options = TransformOptions { + compilation_mode: mode, + resolve_imported_values: true, + ..Default::default() + }; + let result = transform_angular_file( + &allocator, + path.to_str().unwrap(), + source, + Some(&options), + None, + ); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + let dts = result + .dts_declarations + .iter() + .find(|decl| decl.class_name == "M") + .map(|decl| compact(&decl.members)) + .unwrap_or_default(); + (compact(&result.code), dts) + }; + let (full, dts) = run(CompilationMode::Full); + let (partial, _) = run(CompilationMode::Partial); + Out { full, partial, dts } +} + +/// Checks one module: the namespace imports, the full scope, the partial +/// lists and the `.d.ts` lists, as ngtsc writes them (whitespace removed). +fn check(source: &str, imports: &str, scope: &str, partial: &str, dts: &str) { + let out = compile(source); + for code in [&out.full, &out.partial] { + assert!(code.contains(imports), "imports {imports}:\n{code}"); + } + assert!(out.full.contains(&format!("ɵɵsetNgModuleScope(M,{scope})")), "scope:\n{}", out.full); + assert!(out.partial.contains(&format!("type:M,{partial}}}")), "partial:\n{}", out.partial); + assert!(out.dts.contains(&format!("ɵɵNgModuleDeclaration")), "dts:\n{}", out.dts); +} + +const H: &str = "import { NgModule, forwardRef } from '@angular/core';\n"; + +#[test] +fn issue_example_namespace_forward_ref_and_method() { + let source = format!( + "{H}import * as dep from './dep'; +@NgModule({{ imports: [forwardRef(() => dep.DepModule), dep.DepModule.forRoot()] }}) export class M {{}}" + ); + check( + &source, + "import*asi1from'./dep';", + "{imports:[i1.DepModule,i1.DepModule]}", + "imports:[i1.DepModule,i1.DepModule]", + "never,[typeofi1.DepModule,typeofi1.DepModule],never", + ); + let full = compile(&source).full; + assert!( + full.contains( + "ɵɵdefineInjector({imports:[forwardRef(()=>dep.DepModule),dep.DepModule.forRoot()]})" + ), + "{full}" + ); +} + +#[test] +fn issue_example_spread_and_const_list() { + let source = format!( + "{H}import {{ DepModule }} from './dep'; +const LIST = [DepModule]; +@NgModule({{ imports: [...LIST], exports: LIST }}) export class M {{}}" + ); + check( + &source, + "import*asi0from'@angular/core';", + "{imports:[DepModule],exports:[DepModule]}", + "imports:[DepModule],exports:[DepModule]", + "never,[typeofi1.DepModule],[typeofi1.DepModule]", + ); + let full = compile(&source).full; + assert!(full.contains("ɵɵdefineInjector({imports:[LIST,DepModule]})"), "{full}"); +} + +#[test] +fn forward_ref_function_shapes() { + // ngtsc's `expandForwardRef`: an arrow or function expression, with an + // expression body or one `return`; parentheses and `as` around it are + // looked through, and so is an aliased import of `forwardRef`. + for imports in [ + "forwardRef(function () { return dep.DepModule; })", + "forwardRef(() => { return dep.DepModule; })", + "forwardRef((() => dep.DepModule))", + "forwardRef((() => dep.DepModule) as any)", + "forwardRef(() => (dep.DepModule as any))", + "forwardRef(() => dep['DepModule'])", + "fr(() => dep.DepModule)", + ] { + let source = format!( + "import {{ NgModule, forwardRef, forwardRef as fr }} from '@angular/core'; +import * as dep from './dep'; +@NgModule({{ imports: [{imports}] }}) export class M {{}}" + ); + let out = compile(&source); + assert!( + out.full.contains("ɵɵsetNgModuleScope(M,{imports:[i1.DepModule]})"), + "{imports}:\n{}", + out.full + ); + assert!( + out.partial.contains("type:M,imports:[i1.DepModule]}"), + "{imports}:\n{}", + out.partial + ); + assert!( + out.dts.contains(""), + "{imports}:\n{}", + out.dts + ); + } +} + +#[test] +fn forward_ref_to_const_lists() { + // A local list, an imported one, and a package's tuple type. + check( + &format!( + "{H}import {{ DepModule }} from './dep'; +const LIST = [DepModule]; +@NgModule({{ imports: [forwardRef(() => LIST)] }}) export class M {{}}" + ), + "import*asi0from'@angular/core';", + "{imports:[DepModule]}", + "imports:[DepModule]", + "never,[typeofi1.DepModule],never", + ); + check( + &format!( + "{H}import {{ DEP_LIST }} from './dep'; +@NgModule({{ imports: [forwardRef(() => DEP_LIST)] }}) export class M {{}}" + ), + "import*asi1from'./dep';", + "{imports:[i1.DepModule,i1.OtherModule]}", + "imports:[i1.DepModule,i1.OtherModule]", + "never,[typeofi1.DepModule,typeofi1.OtherModule],never", + ); + check( + &format!( + "{H}import {{ LIB_ALL }} from 'fakelib'; +@NgModule({{ imports: [forwardRef(() => LIB_ALL)] }}) export class M {{}}" + ), + "import*asi1from'fakelib';", + "{imports:[i1.LibModule,i1.LibTwoModule]}", + "imports:[i1.LibModule,i1.LibTwoModule]", + "never,[typeofi1.LibModule,typeofi1.LibTwoModule],never", + ); +} + +#[test] +fn namespace_members_in_every_list() { + check( + &format!( + "{H}import * as dep from './dep'; +@NgModule({{ imports: [dep.DepModule], declarations: [forwardRef(() => dep.H)], exports: [forwardRef(() => dep.H), dep.DepModule] }}) export class M {{}}" + ), + "import*asi1from'./dep';", + "{declarations:[i1.H],imports:[i1.DepModule],exports:[i1.H,i1.DepModule]}", + "declarations:[i1.H],imports:[i1.DepModule],exports:[i1.H,i1.DepModule]", + "[typeofi1.H],[typeofi1.DepModule],[typeofi1.H,typeofi1.DepModule]", + ); +} + +#[test] +fn full_mode_scope_numbers_namespaces_before_the_injector() { + // ngtsc numbers `./dep` and `./dep2` in the scope, before ɵinj. + let source = format!( + "{H}import * as d2 from './dep2'; +import {{ LIST2 }} from './dep2'; +@NgModule({{ imports: [...LIST2, d2.Dep2Module.forRoot()], exports: [d2.Dep2Module] }}) export class M {{}}" + ); + check( + &source, + "import*asi1from'./dep';import*asi2from'./dep2';", + "{imports:[i1.DepModule,i2.Dep2Module,i2.Dep2Module],exports:[i2.Dep2Module]}", + "imports:[i1.DepModule,i2.Dep2Module,i2.Dep2Module],exports:[i2.Dep2Module]", + "never,[typeofi1.DepModule,typeofi2.Dep2Module,typeofi2.Dep2Module],[typeofi2.Dep2Module]", + ); + let full = compile(&source).full; + assert!( + full.contains("ɵɵdefineInjector({imports:[LIST2,d2.Dep2Module.forRoot(),i2.Dep2Module]})"), + "{full}" + ); +} + +#[test] +fn a_package_tuple_takes_its_namespace_first() { + let source = format!( + "{H}import {{ LIB_ALL }} from 'fakelib'; +import * as d2 from './dep2'; +@NgModule({{ imports: [...LIB_ALL, d2.Dep2Module], exports: [d2.Dep2Module] }}) export class M {{}}" + ); + check( + &source, + "import*asi1from'fakelib';import*asi2from'./dep2';", + "{imports:[i1.LibModule,i1.LibTwoModule,i2.Dep2Module],exports:[i2.Dep2Module]}", + "imports:[i1.LibModule,i1.LibTwoModule,i2.Dep2Module],exports:[i2.Dep2Module]", + "never,[typeofi1.LibModule,typeofi1.LibTwoModule,typeofi2.Dep2Module],[typeofi2.Dep2Module]", + ); +} + +#[test] +fn module_with_providers_calls() { + // The NgModule each call gives, named through the file that declares + // it, by name, through a namespace, and from a package. + check( + &format!( + "{H}import {{ DepModule, TypedModule, Holder }} from './dep'; +@NgModule({{ imports: [DepModule.forRoot(), TypedModule.forRoot(), Holder.forRoot()] }}) export class M {{}}" + ), + "import*asi1from'./dep';", + "{imports:[i1.DepModule,i1.TypedModule,i1.DepModule]}", + "imports:[i1.DepModule,i1.TypedModule,i1.DepModule]", + "never,[typeofi1.DepModule,typeofi1.TypedModule,typeofi1.DepModule],never", + ); + check( + &format!( + "{H}import * as dep from './dep'; +@NgModule({{ imports: [dep.TypedModule.forRoot(), dep.Holder.forRoot()] }}) export class M {{}}" + ), + "import*asi1from'./dep';", + "{imports:[i1.TypedModule,i1.DepModule]}", + "imports:[i1.TypedModule,i1.DepModule]", + "never,[typeofi1.TypedModule,typeofi1.DepModule],never", + ); + check( + &format!( + "{H}import {{ LibModule }} from 'fakelib'; +import * as lib from 'fakelib'; +@NgModule({{ imports: [LibModule.forRoot({{}}), lib.LibModule.forRoot(), forwardRef(() => lib.LibModule)], exports: [lib.LibModule] }}) export class M {{}}" + ), + "import*asi1from'fakelib';", + "{imports:[i1.LibModule,i1.LibModule,i1.LibModule],exports:[i1.LibModule]}", + "imports:[i1.LibModule,i1.LibModule,i1.LibModule],exports:[i1.LibModule]", + "never,[typeofi1.LibModule,typeofi1.LibModule,typeofi1.LibModule],[typeofi1.LibModule]", + ); +} diff --git a/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs b/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs index 19a16ae0d..dcfd1a99d 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs @@ -1,10 +1,11 @@ //! The lists of a partial `ɵɵngDeclareNgModule` (#623). ngtsc lists what //! each entry gives (`resolveTypeList`): `imports: [mwp, Holder.forRoot()]` //! becomes `imports: [DepModule, DepModule]`, and spreads, nested arrays, -//! `const` lists and namespace members are flattened. The injector and the -//! full-mode `ɵɵsetNgModuleScope` keep the entries as written. In both -//! modes the lists are wrapped in `() => [...]` when one names a class this -//! file declares after the NgModule. +//! `const` lists and namespace members are flattened. The full-mode +//! `ɵɵsetNgModuleScope` lists the same when every entry of a list was read +//! (#703); the injector keeps the entries as written. In both modes the +//! lists are wrapped in `() => [...]` when one names a class this file +//! declares after the NgModule. //! //! Each expected list below was checked against ngtsc 22.2.3 on the same //! input. An entry oxc can't settle keeps its old output. @@ -57,15 +58,17 @@ fn same_file_method_and_constant_list_the_module() { } #[test] -fn full_mode_scope_keeps_the_entries_as_written() { - // ngtsc's full output reads `[DepModule, DepModule]` here, but the - // runtime unwraps the raw list (`convertToTypeArray`), like ngtsc's - // local compilation. Full output doesn't change. +fn full_mode_scope_lists_what_was_read() { + // ngtsc 22.2.4 full: `ɵɵsetNgModuleScope(AppModule, { imports: [DepModule, DepModule] })`. let options = TransformOptions::default(); let code = compile_in("app.ts", SAME_FILE, &options); assert!( - code.contains("ɵɵsetNgModuleScope(AppModule,{imports:[mwp,Holder.forRoot()]})"), - "full-mode scope should be raw:\n{code}" + code.contains("ɵɵsetNgModuleScope(AppModule,{imports:[DepModule,DepModule]})"), + "full-mode scope should list the modules:\n{code}" + ); + assert!( + code.contains("ɵɵdefineInjector({imports:[mwp,Holder.forRoot()]})"), + "ɵinj should keep the entries as written:\n{code}" ); } @@ -317,16 +320,41 @@ export class LaterModule {} } #[test] -fn full_mode_wraps_lists_like_ngtsc_and_keeps_raw_ones() { +fn full_mode_wraps_lists_like_ngtsc() { // ngtsc full: `bootstrap: () => [AppCmp]` and // `{ declarations: () => [AppCmp], imports: () => [EarlyModule, LaterModule], exports: () => [AppCmp] }`. - // The raw `imports` stays as written and unwrapped: the runtime only - // flattens and unwraps a list that is an array (`convertToTypeArray`). let code = full(LATER); assert!(code.contains("ɵɵdefineNgModule({type:AppModule,bootstrap:()=>[AppCmp]})"), "{code}"); assert!( code.contains( - "ɵɵsetNgModuleScope(AppModule,{declarations:()=>[AppCmp],imports:[EarlyModule,Holder.forRoot()],exports:()=>[AppCmp]})" + "ɵɵsetNgModuleScope(AppModule,{declarations:()=>[AppCmp],imports:()=>[EarlyModule,LaterModule],exports:()=>[AppCmp]})" + ), + "{code}" + ); +} + +#[test] +fn full_mode_keeps_a_list_with_an_unread_entry_as_written() { + // `imports` names an import Oxc can't read without cross-file + // analysis, so it stays as written and unwrapped (the runtime only + // flattens and unwraps a list that is an array, `convertToTypeArray`), + // next to the lists that were read and wrapped. + let code = full( + r" +import { NgModule, Directive } from '@angular/core'; +import { DepModule } from './dep'; +@Directive({ selector: '[a]', standalone: false }) +export class A {} +const MODS = [DepModule]; +@NgModule({ declarations: [A, B], imports: [...MODS], exports: [...[A, B]] }) +export class M {} +@Directive({ selector: '[b]', standalone: false }) +export class B {} +", + ); + assert!( + code.contains( + "ɵɵsetNgModuleScope(M,{declarations:()=>[A,B],imports:[...MODS],exports:()=>[A,B]})" ), "{code}" ); @@ -550,8 +578,8 @@ fn lists_oxc_cant_read_keep_their_old_output() { // cross-file analysis (ngtsc: `[DepModule, i1.DepModule]`); // - a spread of a value that is no array, and `satisfies`, which ngtsc // rejects (error -991010); - // - a `let` list, a function call and a condition, which ngtsc reads - // (`[AModule, AModule, AModule]`). + // - a function call and a condition, which ngtsc reads + // (`[AModule, AModule]`). let code = partial( r" import { NgModule, ModuleWithProviders } from '@angular/core'; @@ -563,7 +591,6 @@ export class AModule { } const LOCAL = [DepModule]; const ONE = AModule.forRoot(); -let LET_LIST = [AModule]; function make() { return AModule; } function cond() { return true; } @NgModule({ imports: [...LOCAL, ns.DepModule] }) @@ -572,7 +599,7 @@ export class ImportedModule {} export class OneModule {} @NgModule({ imports: [...([AModule] satisfies any[])] }) export class SatisfiesModule {} -@NgModule({ imports: [...LET_LIST, [make()], ...(cond() ? [AModule] : [])] }) +@NgModule({ imports: [[make()], ...(cond() ? [AModule] : [])] }) export class EvaluatedModule {} ", ); @@ -581,6 +608,32 @@ export class EvaluatedModule {} } } +#[test] +fn let_and_var_lists_are_read() { + // ngtsc reads the initializer of any variable. ngtsc 22.2.4: partial + // `imports: [AModule, AModule], exports: [AModule]`, and the same full + // scope. + let source = r" +import { NgModule, forwardRef } from '@angular/core'; +@NgModule({}) +export class AModule {} +let LET_LIST = [AModule]; +var VAR_LIST = [AModule]; +const LOCAL = [AModule]; +@NgModule({ imports: [...LET_LIST, forwardRef(function () { return LOCAL; })], exports: VAR_LIST }) +export class LetModule {} +"; + let code = partial(source); + assert!(code.contains("type:LetModule,imports:[AModule,AModule],exports:[AModule]}"), "{code}"); + let code = full(source); + assert!( + code.contains( + "ɵɵsetNgModuleScope(LetModule,{imports:[AModule,AModule],exports:[AModule]})" + ), + "{code}" + ); +} + #[test] fn a_spread_in_a_class_in_a_function_keeps_its_old_output() { let code = partial( @@ -805,13 +858,16 @@ export class M {} } #[test] - fn full_mode_scope_stays_raw_with_cross_file_analysis() { + fn full_mode_scope_lists_what_was_read_with_cross_file_analysis() { + // ngtsc full: `import * as i1 from "./dep"` and + // `ɵɵsetNgModuleScope(AppModule, { declarations: [AppCmp], imports: [i1.DepModule, i1.DepModule] })`. let dir = TempDir::new().unwrap(); write(&dir, "dep.ts", DEP); let code = compile(&dir, "app.ts", APP, CompilationMode::Full); + assert!(code.contains("import*asi1from'./dep';"), "{code}"); assert!( code.contains( - "ɵɵsetNgModuleScope(AppModule,{declarations:[AppCmp],imports:[mwp,OtherModuleHolder.forRoot()]})" + "ɵɵsetNgModuleScope(AppModule,{declarations:[AppCmp],imports:[i1.DepModule,i1.DepModule]})" ), "{code}" ); @@ -1035,8 +1091,8 @@ export class NsOnly {} #[test] fn values_oxc_does_not_evaluate_keep_their_old_output() { - // ngtsc also reads the `let` list and the function call: - // `[AModule, i1.OtherModule, i1.OtherModule, AModule, AModule]`, and + // ngtsc also reads the function call: + // `[AModule, i1.OtherModule, i1.OtherModule, AModule]`, and // `[AModule]` for the condition. let dir = TempDir::new().unwrap(); write( @@ -1060,8 +1116,7 @@ import { NgModule } from '@angular/core'; import { LIT, LIT_LIST, DepModule } from './dep'; @NgModule({}) export class AModule {} -let LET_LIST = [AModule]; -@NgModule({ imports: [{ ngModule: AModule, providers: [] }, LIT, ...LIT_LIST, ...LET_LIST, [forwardRefLess()]] }) +@NgModule({ imports: [{ ngModule: AModule, providers: [] }, LIT, ...LIT_LIST, [forwardRefLess()]] }) export class AppModule {} function forwardRefLess() { return AModule; } @NgModule({ imports: [...(cond() ? [AModule] : [DepModule])] }) @@ -1349,11 +1404,11 @@ export class AppModule {} } #[test] - fn a_list_from_a_file_that_loads_this_one_keeps_the_wrap() { - // `cyc.ts` loads this file back, so `LIST` may not be defined yet - // when the NgModule is: full compilation lists `LIST` as written, so - // the wrap a `forwardRef` asked for stays (ngtsc full writes - // `[EarlyCmp, i1.OtherCmp]`). Partial keeps it too. + fn a_list_from_a_file_that_loads_this_one_is_read_like_ngtsc() { + // `cyc.ts` loads this file back, but the list's class is named + // through `other.ts`, which doesn't: like ngtsc 22.2.4, full gives + // `ɵɵsetNgModuleScope(AppModule, { declarations: [EarlyCmp, i1.OtherCmp] })` + // and partial `declarations: [EarlyCmp, i1.OtherCmp]`, no wrap. let dir = TempDir::new().unwrap(); write( &dir, @@ -1375,19 +1430,46 @@ export const TITLE = 'x'; export class AppModule {} "; let code = compile(&dir, "app.ts", source, CompilationMode::Full); + assert!(code.contains("import*asi1from'./other';"), "{code}"); assert!( - code.contains("ɵɵsetNgModuleScope(AppModule,{declarations:()=>[EarlyCmp,LIST]})"), + code.contains("ɵɵsetNgModuleScope(AppModule,{declarations:[EarlyCmp,i1.OtherCmp]})"), "{code}" ); let code = compile(&dir, "app.ts", source, CompilationMode::Partial); - assert!(code.contains("type:AppModule,declarations:()=>[EarlyCmp,i1.OtherCmp]}"), "{code}"); + assert!(code.contains("type:AppModule,declarations:[EarlyCmp,i1.OtherCmp]}"), "{code}"); } #[test] - fn a_declaration_file_list_keeps_its_old_output() { - // ngtsc reads the tuple type (`[i1.LibModule, i1.LibDir]`); oxc - // doesn't here, so `...WRAPPED` keeps its old output instead of - // naming the list as a class. + fn a_forward_ref_to_a_class_whose_file_loads_this_one_keeps_the_wrap() { + // `cyc.ts` declares `CycModule` and loads this file back. ngtsc + // 22.2.4 writes `imports: [i1.CycModule]` (full and partial), which + // throws when `cyc.ts` is loaded first: this file then runs before + // `CycModule` is defined. Oxc runs the full-mode scope with no + // `ngJitMode` guard, so it keeps the lists lazy instead. + let dir = TempDir::new().unwrap(); + write( + &dir, + "cyc.ts", + "import { NgModule } from '@angular/core';\nimport { M } from './app';\n@NgModule({}) export class CycModule { static m = () => M; }\n", + ); + let source = r" +import { NgModule, forwardRef } from '@angular/core'; +import * as c from './cyc'; +@NgModule({ imports: [forwardRef(() => c.CycModule)] }) +export class M {} +"; + let code = compile(&dir, "app.ts", source, CompilationMode::Full); + assert!(code.contains("ɵɵsetNgModuleScope(M,{imports:()=>[i1.CycModule]})"), "{code}"); + let code = compile(&dir, "app.ts", source, CompilationMode::Partial); + assert!(code.contains("type:M,imports:()=>[i1.CycModule]}"), "{code}"); + } + + #[test] + fn a_declaration_file_list_is_read_by_its_type() { + // ngtsc's evaluator reads a `declare const` by its tuple type, also + // through a list of another file and a nested tuple. ngtsc 22.2.4 + // gives `import * as i1 from "fakelib"` and + // `[i1.LibModule, i1.LibDir]`, never `LIB_ALL` as a class. let dir = TempDir::new().unwrap(); write( &dir, @@ -1406,6 +1488,7 @@ export declare class LibDir { static ɵdir: i0.ɵɵDirectiveDeclaration; } export declare const LIB_ALL: [typeof LibModule, typeof LibDir]; +export declare const LIB_NESTED: readonly [typeof LIB_ALL, typeof LibModule]; "#, ); write( @@ -1414,13 +1497,38 @@ export declare const LIB_ALL: [typeof LibModule, typeof LibDir]; "import { LIB_ALL } from 'fakelib';\nexport const WRAPPED = [...LIB_ALL];\n", ); let source = r" -import { NgModule } from '@angular/core'; +import { NgModule, forwardRef } from '@angular/core'; import { WRAPPED } from './lib'; +import * as lib from 'fakelib'; @NgModule({ imports: [...WRAPPED] }) export class M2 {} +@NgModule({ imports: [forwardRef(() => lib.LIB_ALL)], exports: [...lib.LIB_NESTED] }) +export class M3 {} "; - let code = compile(&dir, "app.ts", source, CompilationMode::Partial); - assert!(code.contains("type:M2}"), "{code}"); - assert!(!code.contains("LIB_ALL"), "{code}"); + for mode in [CompilationMode::Partial, CompilationMode::Full] { + let code = compile(&dir, "app.ts", source, mode); + assert!(code.contains("import*asi1from'fakelib';"), "{code}"); + assert!(!code.contains("i1.LIB_"), "{code}"); + if matches!(mode, CompilationMode::Partial) { + assert!(code.contains("type:M2,imports:[i1.LibModule,i1.LibDir]}"), "{code}"); + assert!( + code.contains( + "type:M3,imports:[i1.LibModule,i1.LibDir],exports:[i1.LibModule,i1.LibDir,i1.LibModule]}" + ), + "{code}" + ); + } else { + assert!( + code.contains("ɵɵsetNgModuleScope(M2,{imports:[i1.LibModule,i1.LibDir]})"), + "{code}" + ); + assert!( + code.contains( + "ɵɵsetNgModuleScope(M3,{imports:[i1.LibModule,i1.LibDir],exports:[i1.LibModule,i1.LibDir,i1.LibModule]})" + ), + "{code}" + ); + } + } } } From f73f6efa91f22f537590d0ed94586193b3cf66fd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:06:46 +0800 Subject: [PATCH 08/22] test(napi): run NgModule scopes read across files with ngJitMode off (#703) Compiles small projects in full mode with cross-file analysis and loads them in plain Node with the real @angular/core: - a scope listing what forwardRef(() => ns.X), a spread of an imported list and ns.X.forRoot() give still hands a declared component the directives of those modules; - a list with an entry Oxc can't read stays as written next to a read, wrapped one, and still works; - an import cycle keeps the lists lazy and loads in either order, while the eager list ngtsc writes there throws when the other file loads first. Co-Authored-By: Claude Opus 5.5 --- .../test/ng-module-list-scope-runtime.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 napi/angular-compiler/test/ng-module-list-scope-runtime.test.ts diff --git a/napi/angular-compiler/test/ng-module-list-scope-runtime.test.ts b/napi/angular-compiler/test/ng-module-list-scope-runtime.test.ts new file mode 100644 index 000000000..233be2bc4 --- /dev/null +++ b/napi/angular-compiler/test/ng-module-list-scope-runtime.test.ts @@ -0,0 +1,176 @@ +/** + * NgModule scopes Oxc reads across files (#703), at runtime with the real + * `@angular/core` and `ngJitMode = false`. + * + * With cross-file analysis, the full-mode `ɵɵsetNgModuleScope` lists the + * classes a list gives, like ngtsc (`forwardRef(() => ns.X)`, spreads of + * imported lists, `ns.X.forRoot()`), instead of the list as written. Oxc + * runs that call in production too, so the listed classes must give a + * declared component the same directives. A list with an entry Oxc can't + * read stays as written, next to lists that were read. When the file of a + * listed class loads this one back, the lists stay lazy (`() => [...]`). + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { transformAngularFileSync } from '../index.js' + +const TMP = join(fileURLToPath(new URL('.', import.meta.url)), '.ng-module-list-scope-tmp') +let nextCase = 0 + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }) +}) + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }) +}) + +/** + * Writes `files` (`.ts` sources importing each other as `./name`), compiles + * each in full mode with cross-file analysis, and writes it as `name.mjs`. + * Returns the directory and the compiled code of each file. + */ +function build(files: Record): { dir: string; code: Record } { + const dir = join(TMP, `case-${nextCase++}`) + mkdirSync(dir, { recursive: true }) + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(dir, `${name}.ts`), source) + } + const code: Record = {} + for (const [name, source] of Object.entries(files)) { + const result = transformAngularFileSync(source, join(dir, `${name}.ts`), { + resolveImportedValues: true, + }) + expect(result.errors).toEqual([]) + expect(result.code).not.toContain('ngJitMode') + code[name] = result.code + writeMjs(dir, name, result.code, Object.keys(files)) + } + return { dir, code } +} + +/** Writes `code` as `name.mjs`, its imports of `names` pointing at `.mjs`. */ +function writeMjs(dir: string, name: string, code: string, names: string[]) { + let out = code + for (const other of names) { + out = out + .replaceAll(`'./${other}'`, `'./${other}.mjs'`) + .replaceAll(`"./${other}"`, `"./${other}.mjs"`) + } + writeFileSync(join(dir, `${name}.mjs`), out) +} + +/** + * Loads `first`, then prints, sorted, the directives the template of `cmp` (an export + * of `app`) can use, in plain Node: Vitest's module runner reads an import + * that isn't initialized yet as `undefined` instead of throwing. + */ +function directiveNames(dir: string, first: string, cmp = 'C'): string { + const script = ` + globalThis.ngJitMode = false; + globalThis.ngDevMode = false; + await import(${JSON.stringify(pathToFileURL(join(dir, `${first}.mjs`)).href)}); + const app = await import('./app.mjs'); + const defs = app.${cmp}.ɵcmp.directiveDefs; + const list = typeof defs === 'function' ? defs() : (defs ?? []); + console.log(list.map((def) => def.type.name).sort().join(',')); + ` + return execFileSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: dir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +const DEP = ` + import { Directive, NgModule } from '@angular/core'; + @Directive({ selector: '[d]', standalone: false }) export class D {} + @Directive({ selector: '[o]', standalone: false }) export class O {} + @NgModule({ declarations: [D], exports: [D] }) + export class DepModule { + static forRoot() { return { ngModule: DepModule, providers: [] }; } + } + @NgModule({ declarations: [O], exports: [O] }) export class OtherModule {} + export const DEP_LIST = [DepModule]; +` + +describe('NgModule scope read across files, with ngJitMode = false', () => { + it('gives a declared component the directives of the modules a list gives', () => { + const { dir, code } = build({ + dep: DEP, + app: ` + import { Component, NgModule, forwardRef } from '@angular/core'; + import * as dep from './dep'; + import { DEP_LIST } from './dep'; + @Component({ selector: 'c', template: '', standalone: false }) export class C {} + @NgModule({ + declarations: [C], + imports: [forwardRef(() => dep.OtherModule), ...DEP_LIST, dep.DepModule.forRoot()], + }) + export class M {} + `, + }) + expect(code.app.replace(/\s+/g, '')).toContain( + 'ɵɵsetNgModuleScope(M,{declarations:[C],imports:[i1.OtherModule,i1.DepModule,i1.DepModule]})', + ) + expect(directiveNames(dir, 'app')).toBe('C,D,O') + }) + + it('keeps a list with an entry it cannot read as written, next to a read one', () => { + const { dir, code } = build({ + dep: DEP, + app: ` + import { Component, Directive, NgModule } from '@angular/core'; + import * as dep from './dep'; + function other() { return dep.OtherModule; } + @Component({ selector: 'c', template: '', standalone: false }) export class C {} + @NgModule({ declarations: [C, Later], imports: [...dep.DEP_LIST, other()] }) + export class M {} + @Directive({ selector: '[l]', standalone: false }) export class Later {} + `, + }) + expect(code.app.replace(/\s+/g, '')).toContain( + 'ɵɵsetNgModuleScope(M,{declarations:()=>[C,Later],imports:[...dep.DEP_LIST,other()]})', + ) + expect(directiveNames(dir, 'app')).toBe('C,D,Later,O') + }) + + it('keeps the lists lazy when the file of a listed class loads this one back', () => { + const files = { + cyc: ` + import { Directive, NgModule } from '@angular/core'; + import { M } from './app'; + @Directive({ selector: '[x]', standalone: false }) export class X {} + @NgModule({ declarations: [X], exports: [X] }) + export class CycModule { static app = () => M; } + `, + app: ` + import { Component, NgModule, forwardRef } from '@angular/core'; + import * as c from './cyc'; + @Component({ selector: 'c', template: '', standalone: false }) export class C {} + @NgModule({ declarations: [C], imports: [forwardRef(() => c.CycModule)] }) + export class M {} + `, + } + const { dir, code } = build(files) + const scope = 'ɵɵsetNgModuleScope(M,{declarations:()=>[C],imports:()=>[i1.CycModule]})' + expect(code.app.replace(/\s+/g, '')).toContain(scope) + // Loaded either way round: from `cyc`, `app` runs before `CycModule` + // is defined. + for (const first of ['app', 'cyc']) { + expect(directiveNames(dir, first)).toBe('C,X') + } + // ngtsc writes the lists without the wrap here, which throws when + // `cyc` is loaded first. + const eager = code.app.replace(/\(\)\s*=>\s*\[i1\.CycModule\]/, '[i1.CycModule]') + expect(eager).not.toBe(code.app) + writeMjs(dir, 'app', eager, Object.keys(files)) + expect(directiveNames(dir, 'app')).toBe('C,X') + expect(() => directiveNames(dir, 'cyc')).toThrow(/before initialization/) + }) +}) From cc3fd928a14754958b7229b3f80266b23b3413f9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:07:12 +0800 Subject: [PATCH 09/22] fix(host): report empty `[]` / `()` host keys only from 21.2.8 / 22.0.0-next.7 (#704) Before 21.2.8 and on 22.0.0-next.0 to next.6, ngtsc sorts host keys with HOST_REG_EXP, which needs a name inside the brackets. So `[]` and `()` are plain host attributes there and nothing is reported. Oxc reported them on every Angular version and failed builds that ngtsc accepts. Run the two checks only when the version has the newer key parsing. The metadata read for codegen skips them, as it does not know the version. Co-Authored-By: Claude Opus 5.5 --- .../src/component/metadata.rs | 14 ++++ .../src/component/transform.rs | 8 ++- .../src/directive/decorator.rs | 71 ++++++++++++++----- .../tests/host_metadata_evaluation_test.rs | 51 ++++++++++++- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 934fd423d..4c4e256eb 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -478,6 +478,20 @@ impl AngularVersion { && self.cmp_release(&Self::new(20, 2, 0).with_prerelease(Prerelease::Next(2))).is_ge() } + /// Check if this version reports a `[]` / `()` host key ("Property name + /// is missing in binding" / "Event name is missing in binding"). + /// + /// Older versions sort host keys with + /// `HOST_REG_EXP = /^(?:\[([^\]]+)\])|(?:\(([^\)]+)\))$/`, which needs a + /// name, so `[]` and `()` are plain host attributes there. 21.2.8 and + /// 22.0.0-next.7 replaced it with `startsWith` / `endsWith` checks. + pub fn reports_empty_host_binding_names(&self) -> bool { + self.fixed_since(&[ + Self::new(21, 2, 8), + Self::new(22, 0, 0).with_prerelease(Prerelease::Next(7)), + ]) + } + /// Whether a fix is in this version, given the first fixed release on /// each release line, oldest line first. /// diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b9b2ba12a..e10c1019a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3969,7 +3969,13 @@ pub fn transform_angular_file( let implicit_standalone = options.implicit_standalone(); // `inputs:`/`outputs:` forms ngtsc rejects, rather than dropping them silently. - let io_errors = decorator_io_errors(allocator, class, Some(source), &string_consts); + let io_errors = decorator_io_errors( + allocator, + class, + Some(source), + &string_consts, + options.angular_version, + ); result.diagnostics.extend(io_errors.iter().cloned()); if io_errors.is_empty() { // Constructor parameter decorators: ngtsc's diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index bfaf258c8..a4f683a33 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -21,6 +21,7 @@ use super::metadata::{ R3InputMetadata, }; use super::property_decorators::try_unwrap_forward_ref; +use crate::component::AngularVersion; use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputAstBuilder, OutputExpression}; use crate::output::oxc_converter::{convert_oxc_expression, make_raw_source}; @@ -489,7 +490,7 @@ pub struct StringConsts<'a> { /// `["k", "KeyCodes"]`), whose import the cross-file analysis followed /// to a `const enum` (see [`Self::is_imported_const_enum`]). imported_const_enums: rustc_hash::FxHashSet>>, - /// `host:` expressions already run through [`evaluate_host_metadata`], + /// `host:` expressions already run through [`parse_host_metadata`], /// keyed by the expression's address and the decorator's name: the /// diagnostic pass (`decorator_io_errors`) and the metadata extraction /// both evaluate it, and evaluation is deterministic. @@ -1211,6 +1212,7 @@ pub fn decorator_io_errors<'a>( class: &'a Class<'a>, source_text: Option<&'a str>, consts: &StringConsts<'a>, + angular_version: Option, ) -> std::vec::Vec { use super::property_decorators::{ IO_ACCESS, decorator_argument, initializer_api_access_error, literal_alias_error, @@ -1542,7 +1544,8 @@ pub fn decorator_io_errors<'a>( // to the member name and `parse_host_listener_config` drops a non-string // argument instead of reporting it — except for a string with a lone // surrogate, which oxc can't read. - let host = || host_metadata_error(allocator, config, decorator_name, consts).map(at); + let host = + || host_metadata_error(allocator, config, decorator_name, consts, angular_version).map(at); let host_members = || super::property_decorators::lone_surrogate_host_error(class, consts).map(at); @@ -2006,6 +2009,22 @@ pub(crate) fn evaluate_host_metadata<'a>( expr: &'a Expression<'a>, decorator_name: &str, consts: &StringConsts<'a>, +) -> Result, HostValue<'a>)>, String> { + let entries = parse_host_metadata(allocator, expr, decorator_name, consts)?; + match host_binding_errors(&entries, false) { + Some(error) => Err(error), + None => Ok(entries), + } +} + +/// The entries of the `host` metadata `expr` evaluates to, or the error +/// ngtsc's `parseHostBindings` reports for it. The checks of +/// `verifyHostBindings` are left to [`host_binding_errors`]. +fn parse_host_metadata<'a>( + allocator: &'a Allocator, + expr: &'a Expression<'a>, + decorator_name: &str, + consts: &StringConsts<'a>, ) -> Result, HostValue<'a>)>, String> { // This runs twice per decorated class — the diagnostic checks it and the // metadata extraction reads the entries — so each `host:` expression is @@ -2014,12 +2033,12 @@ pub(crate) fn evaluate_host_metadata<'a>( if let Some(cached) = consts.host_values.borrow().get(&key) { return cached.clone(); } - let result = evaluate_host_metadata_uncached(allocator, expr, decorator_name, consts); + let result = parse_host_metadata_uncached(allocator, expr, decorator_name, consts); consts.host_values.borrow_mut().insert(key, result.clone()); result } -fn evaluate_host_metadata_uncached<'a>( +fn parse_host_metadata_uncached<'a>( allocator: &'a Allocator, expr: &'a Expression<'a>, decorator_name: &str, @@ -2115,6 +2134,17 @@ fn evaluate_host_metadata_uncached<'a>( if let Some(error) = parse_error { return Err(error); } + Ok(entries) +} + +/// The error ngtsc's `verifyHostBindings` reports for the parsed `host` +/// `entries`, if any. The checks for a `[]` / `()` key run only when +/// `check_empty_names` is set (see +/// [`AngularVersion::reports_empty_host_binding_names`]). +fn host_binding_errors( + entries: &[(Ident<'_>, HostValue<'_>)], + check_empty_names: bool, +) -> Option { // `verifyHostBindings` (compiler.ts) collects every error, in this order, // and ngtsc joins their messages with "\n" into one NG5001: // 1. `createDirectiveHostEventAsts`: `()` has no event name. @@ -2125,17 +2155,19 @@ fn evaluate_host_metadata_uncached<'a>( // expressions, which oxc doesn't do. Only the name inside the brackets is // checked, so `[ ]` and `( )` are not errors. let mut errors: std::vec::Vec = std::vec::Vec::new(); - for (key, _) in &entries { - if matches!(HostKey::of(key.as_str()), HostKey::Listener) && key.len() == 2 { - errors.push("Event name is missing in binding".to_string()); + if check_empty_names { + for (key, _) in entries { + if matches!(HostKey::of(key.as_str()), HostKey::Listener) && key.len() == 2 { + errors.push("Event name is missing in binding".to_string()); + } } - } - for (key, _) in &entries { - if matches!(HostKey::of(key.as_str()), HostKey::Property) && key.len() == 2 { - errors.push("Property name is missing in binding".to_string()); + for (key, _) in entries { + if matches!(HostKey::of(key.as_str()), HostKey::Property) && key.len() == 2 { + errors.push("Property name is missing in binding".to_string()); + } } } - for (key, _) in &entries { + for (key, _) in entries { let HostKey::Property = HostKey::of(key.as_str()) else { continue }; let prop = &key.as_str()[1..key.as_str().len() - 1]; let is_attr = prop.starts_with("attr."); @@ -2156,10 +2188,7 @@ fn evaluate_host_metadata_uncached<'a>( errors.push(msg); } } - if !errors.is_empty() { - return Err(errors.join("\n")); - } - Ok(entries) + (!errors.is_empty()).then(|| errors.join("\n")) } /// The error ngtsc reports for the `host` of a `@Component` / `@Directive` whose @@ -2169,11 +2198,15 @@ fn host_metadata_error<'a>( config: Option<&'a ObjectExpression<'a>>, decorator_name: &str, consts: &StringConsts<'a>, + angular_version: Option, ) -> 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 check_empty_names = angular_version.is_none_or(|v| v.reports_empty_host_binding_names()); + let error = match parse_host_metadata(allocator, expr, decorator_name, consts) { + Ok(entries) => host_binding_errors(&entries, check_empty_names)?, + Err(error) => error, + }; + Some((error, expr.span())) } /// Extract host metadata from a host expression. diff --git a/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs index 4c0b06934..26493e9d4 100644 --- a/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs +++ b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs @@ -8,7 +8,7 @@ //! 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_angular_compiler::{AngularVersion, TransformOptions, transform_angular_file}; use oxc_diagnostics::OxcDiagnostic; const MEMBERS: &str = "go() {} other() {} t = ''; on = true; w = '';"; @@ -743,6 +743,55 @@ fn empty_binding_name_is_an_error() { ); } +/// The `[]` / `()` checks came with 21.2.8 and 22.0.0-next.7. Before them +/// ngtsc's `HOST_REG_EXP` needs a name, `[]` and `()` are plain host +/// attributes and nothing is reported (ngtsc 19.2.15, 20.1.8, 20.2.0; +/// `parseHostBindings` + `verifyHostBindings` of 21.2.7 and 22.0.0-next.6). +/// The event-binding check runs on every version. +#[test] +fn empty_binding_name_is_reported_from_21_2_8_and_22_0_0_next_7() { + use oxc_angular_compiler::Prerelease; + let next = |n| AngularVersion::new(22, 0, 0).with_prerelease(Prerelease::Next(n)); + let source = source("Directive", "", "{ '[]': 't', '()': 'go()' }"); + let errors = |version: AngularVersion| -> Vec { + let allocator = Allocator::default(); + let options = + TransformOptions { angular_version: Some(version), ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "/x/a.ts", &source, Some(&options), None); + result.diagnostics.into_iter().map(|d| d.message.to_string()).collect() + }; + let both = vec!["Event name is missing in binding\nProperty name is missing in binding"]; + for version in [ + AngularVersion::new(19, 2, 15), + AngularVersion::new(20, 3, 35), + AngularVersion::new(21, 0, 9), + AngularVersion::new(21, 2, 7), + next(6), + ] { + assert!(errors(version).is_empty(), "{version:?}"); + } + for version in [AngularVersion::new(21, 2, 8), next(7), AngularVersion::new(22, 0, 0)] { + assert_eq!(errors(version), both, "{version:?}"); + } + + let source = source.replace("'()': 'go()'", "'[onclick]': 't'"); + let allocator = Allocator::default(); + let options = TransformOptions { + angular_version: Some(AngularVersion::new(21, 2, 7)), + ..TransformOptions::default() + }; + let result = transform_angular_file(&allocator, "/x/a.ts", &source, Some(&options), None); + let messages: Vec = result.diagnostics.iter().map(|d| d.message.to_string()).collect(); + assert_eq!( + messages, + vec![ + "Binding to event property 'onclick' is disallowed for security reasons, please use \ + (click)=...\nIf 'onclick' is a directive input, make sure the directive is imported \ + by the current module." + ] + ); +} + /// Only the name inside the brackets is checked, so `[ ]` and `( )` compile /// (ngtsc 22.2.4 with `typeCheckHostBindings: false`; its host type-check /// reports NG8002 for `' '` otherwise, which oxc doesn't do). The decorators From aeea8cfde1c9054b45ee522bc0ebede26a8da196 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:07:55 +0800 Subject: [PATCH 10/22] fix(vite): embed the client page of the same round in `vite build --app --watch` (#705) In watch mode the client and server environments build at the same time, so the server read the page of the previous client round (or the source page in round 1), and a client-only change never rebuilt the server. Now, in `vite build --app --watch` only: - The server manifest holds two free names at transform time. The server's renderChunk waits for the client round in progress (or the first one, once the client watcher exists) and fills in that round's index.html, like the Angular CLI builds index.server.html from the browser page of the same build. - Only the environment named `client` gives the page and ends a round. A round also ends on buildEnd(error), renderError and the client watcher's ERROR event, so a failed client round never blocks the server. The server then uses the last page and warns. - Each watched server watches a file in the temp dir, made in its buildStart, so it exists before the build ends. The client writes it when its page differs from the one a server embedded and that server is not waiting, so a client-only change rebuilds the server, also when client round 1 failed in a fresh outDir. Builds without watch keep the same output and warnings. Co-Authored-By: Claude Opus 5.5 --- .../e2e/tests/ssr-manifest.spec.ts | 77 +++- .../test/ssr-manifest.test.ts | 292 +++++++++++-- .../angular-ssr-manifest-plugin.ts | 410 ++++++++++++++---- 3 files changed, 658 insertions(+), 121 deletions(-) diff --git a/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts b/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts index eeffcbc04..339a35191 100644 --- a/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts +++ b/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts @@ -7,7 +7,7 @@ * * @see https://github.com/voidzero-dev/oxc-angular-compiler/issues/60 */ -import { execSync } from 'node:child_process' +import { execSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -167,6 +167,81 @@ export default defineConfig({ rmSync(SSR_OUT_DIR, { recursive: true, force: true }) }) + test('vite build --app --watch embeds the client page of each round (#705)', async () => { + test.setTimeout(180000) + rmSync(SSR_OUT_DIR, { recursive: true, force: true }) + + const mainPath = join(APP_DIR, 'src/main.ts') + const mainSource = readFileSync(mainPath, 'utf-8') + const vite = join(APP_DIR, 'node_modules/vite/bin/vite.js') + const child = spawn( + process.execPath, + [vite, 'build', '--app', '--watch', '--config', 'vite.config.app.ts'], + { cwd: APP_DIR, stdio: 'pipe' }, + ) + let log = '' + child.stdout.on('data', (data) => (log += data)) + child.stderr.on('data', (data) => (log += data)) + + // A watch round empties the outDir before it writes it again. + const read = (path: string) => { + try { + return readFileSync(join(SSR_OUT_DIR, path), 'utf-8') + } catch { + return undefined + } + } + const clientEntry = () => read('client/index.html')?.match(/\/assets\/index-[^"]+\.js/)?.[0] + + /** + * Waits until the client page loads another entry than `previous`, the + * server embeds it, and no build has logged for a while. + */ + async function settle(previous?: string) { + const deadline = Date.now() + 90000 + let seen = log.length + let quietSince = Date.now() + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 250)) + if (log.length !== seen) { + seen = log.length + quietSince = Date.now() + continue + } + const entry = clientEntry() + const server = read('server/server.js') + if ( + entry && + entry !== previous && + server?.includes(entry) && + Date.now() - quietSince > 3000 + ) { + return { entry, server } + } + } + throw new Error( + `the server did not embed the client page ${clientEntry()} (before: ${previous}):\n${log}`, + ) + } + + try { + // Round 1, fresh outDir. + const first = await settle() + expect(first.server).toContain('index.server.html') + expect(first.server).not.toContain('/src/main.ts') + + // A client-only change: the server has no import of main.ts. + writeFileSync(mainPath, `${mainSource}\nconsole.log('oxc-705')\n`) + const second = await settle(first.entry) + expect(second.server).not.toContain(first.entry) + expect(log).not.toContain('vite-ssr-manifest') + } finally { + child.kill() + writeFileSync(mainPath, mainSource) + rmSync(SSR_OUT_DIR, { recursive: true, force: true }) + } + }) + test('vite build --ssr without an app engine does not warn about index.html (#696)', () => { writeTempFile('src/plain-server.ts', `export const render = () => '';`) const output = execSync( diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index d4e5b1585..493109799 100644 --- a/napi/angular-compiler/test/ssr-manifest.test.ts +++ b/napi/angular-compiler/test/ssr-manifest.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runInNewContext } from 'node:vm' import { afterAll, describe, it, expect } from 'vitest' /** @@ -148,7 +149,7 @@ describe('SSR Manifest Generation (Issue #60)', () => { it('should do nothing outside Vite (plain rolldown has no environment)', async () => { const plugin = ssrManifestPlugin({}) ;(plugin.configResolved as Function)({ root: '/app', base: '/' }) - await (plugin.buildStart as Function).call({}) + await (plugin.buildStart as any).handler.call({}) expect( (plugin.transform as Function).call( {}, @@ -166,7 +167,11 @@ describe('SSR Manifest Generation (Issue #60)', () => { '' const roots: string[] = [] - afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true }))) + // The watched files of watch mode, in the temp dir. + const stamps: string[] = [] + afterAll(() => + [...roots, ...stamps].forEach((path) => rmSync(path, { recursive: true, force: true })), + ) /** A root with the source index.html and, when given, a built `dist/client/index.html`. */ function tempRoot(built?: string): string { @@ -185,14 +190,17 @@ describe('SSR Manifest Generation (Issue #60)', () => { 'index.html': { type: 'asset', fileName: 'index.html', source }, }) - /** One plugin shared by a `client` and an `ssr` environment, listed in `order`. */ + /** + * One plugin shared by a `client` and an `ssr` environment, listed in + * `order`. `watch` is true for all environments or names the ones that watch. + */ function app( root: string, { builder, watch, order = ['client', 'ssr'], - }: { builder?: boolean; watch?: boolean; order?: string[] } = {}, + }: { builder?: boolean; watch?: boolean | string[]; order?: string[] } = {}, ) { const plugin = ssrManifestPlugin({}) as any plugin.configResolved({ root, base: '/' }) @@ -200,36 +208,60 @@ describe('SSR Manifest Generation (Issue #60)', () => { order.map((name) => [name, name === 'client' ? { build: { outDir: 'dist/client' } } : {}]), ) const warnings: string[] = [] - const context = (name: string) => ({ + const watched: string[] = [] + const watches = (name: string) => + watch === true || (Array.isArray(watch) && watch.includes(name)) + const context = (name: string, consumer = name === 'ssr' ? 'server' : 'client') => ({ environment: { name, config: { - consumer: name === 'client' ? 'client' : 'server', + consumer, root, - build: { watch: watch ? {} : null }, + build: { watch: watches(name) ? {} : null }, environments, }, getTopLevelConfig: () => ({ builder: builder ? {} : undefined }), }, - meta: { watchMode: !!watch }, + meta: { watchMode: watches(name) }, warn: (message: string) => warnings.push(message), + addWatchFile: (file: string) => watched.push(file), }) return { + plugin, warnings, - clientStart: () => plugin.buildStart.call(context('client')), - clientBundle: (bundle: Record) => - plugin.generateBundle.handler.call(context('client'), {}, bundle), + watched, + clientStart: () => plugin.buildStart.handler.call(context('client')), + /** The generateBundle of the `client` environment, or of another one (`name`). */ + clientBundle: (bundle: Record, name = 'client') => + plugin.generateBundle.handler.call(context(name), {}, bundle), + clientEnd: (error?: Error) => plugin.buildEnd.call(context('client'), error), + clientRenderError: () => plugin.renderError.call(context('client'), new Error('client')), /** The server's buildStart, then a transform of each module. */ async server(...modules: string[]): Promise<(string | undefined)[]> { - await plugin.buildStart.call(context('ssr')) + await plugin.buildStart.handler.call(context('ssr')) return modules.map( (code, i) => plugin.transform.call(context('ssr'), code, join(root, `src/server${i}.ts`))?.code, ) }, + /** The server's renderChunk of a chunk with `code`. */ + async render(code: string): Promise { + return (await plugin.renderChunk.call(context('ssr'), code))?.code + }, } } + /** Whether `promise` settles within a short time. */ + async function settles(promise: Promise): Promise { + let done = false + promise.then( + () => (done = true), + () => (done = true), + ) + await new Promise((r) => setTimeout(r, 50)) + return done + } + /** Runs the client's generateBundle (when given a bundle), then the server build. */ async function serverBuild( root: string, @@ -240,7 +272,7 @@ describe('SSR Manifest Generation (Issue #60)', () => { }: { bundle?: Record; builder?: boolean; order?: string[] } = {}, ) { const build = app(root, { builder, order }) - if (bundle) build.clientBundle(bundle) + if (bundle) await build.clientBundle(bundle) const [code] = await build.server(ENGINE) return { code: code!, warnings: build.warnings } } @@ -329,40 +361,211 @@ describe('SSR Manifest Generation (Issue #60)', () => { expect(build.warnings).toHaveLength(2) }) - it('does not silently reuse the previous round in watch mode', async () => { - // `vite build --app --watch` builds the environments at the same time. - const root = tempRoot() - const build = app(root, { builder: true, watch: true }) + describe('vite build --app --watch (#705)', () => { + // The Angular CLI builds index.server.html from the browser index.html of + // the same build (@angular/build execute-post-bundle.js), so each server + // round must embed the page of the same client round. const NEXT = BUILT.replace('index-abc', 'index-def') + const filled = (html: string) => 'Promise.resolve(`' + html + '`)' + const watchApp = (root = tempRoot()) => app(root, { builder: true, watch: true }) + + /** Runs the plugin's buildApp, then `builder.build` of the client, which returns a watcher. */ + async function startClientWatcher(build: ReturnType) { + const listeners: ((event: { code: string }) => void)[] = [] + const watcher = { + on: (_: string, listener: (event: { code: string }) => void) => listeners.push(listener), + } + const builder = { environments: { client: { isBuilt: false } }, build: async () => watcher } + await build.plugin.buildApp.handler.call({}, builder) + // The plugin wraps `build`, which takes the environment. + await (builder.build as (environment: { name: string }) => Promise)({ + name: 'client', + }) + return (code: string) => listeners.forEach((listener) => listener({ code })) + } - // Round 1: the server starts before the client has built. - await build.clientStart() - const [first] = await build.server(ENGINE) - build.clientBundle(page(BUILT)) - expect(first).toContain('/src/main.ts') - expect(build.warnings).toHaveLength(1) - expect(build.warnings[0]).toContain('In watch mode') - expect(build.warnings[0]).not.toContain('List `client` first') + it('fills in the page of the same client round', async () => { + const build = watchApp() - // Round 2: the client starts first and drops its old page, so the server - // reads the client outDir of round 1. - mkdirSync(join(root, 'dist/client'), { recursive: true }) - writeFileSync(join(root, 'dist/client/index.html'), BUILT) - await build.clientStart() - const [second] = await build.server(ENGINE) - build.clientBundle(page(NEXT)) - expect(second).toContain('/assets/index-abc.js') - expect(build.warnings).toHaveLength(2) - expect(build.warnings[1]).toContain('may be stale') - expect(build.warnings[1]).toContain('In watch mode') + // Round 1: the server renders while the client round runs. + await build.clientStart() + const [code] = await build.server(ENGINE) + expect(code).toContain('size: __oxc_angular_index_server_html_size__,') + expect(code).toContain('text: () => Promise.resolve(__oxc_angular_index_server_html__),') + expect(code).not.toContain('/src/main.ts') + const first = build.render(code!) + build.clientEnd() + expect(await settles(first)).toBe(false) + await build.clientBundle(page(BUILT)) + const out = await first + expect(out).toContain('size: 130,') + expect(out).toContain(filled(BUILT)) + expect(out).not.toContain('__oxc_angular') + // The server waited for this page, so the client does not rebuild it. + expect(build.watched).toHaveLength(1) + expect(readFileSync(build.watched[0], 'utf-8')).toBe('') + + // Round 2: a server-only change takes the latest page at once. + const [serverOnly] = await build.server(ENGINE) + expect(await build.render(serverOnly!)).toContain(filled(BUILT)) + + // Round 3: a shared change waits for the new page. + await build.clientStart() + const [shared] = await build.server(ENGINE) + const third = build.render(shared!) + expect(await settles(third)).toBe(false) + await build.clientBundle(page(NEXT)) + expect(await third).toContain(filled(NEXT)) + expect(build.warnings).toEqual([]) + }) - // Round 3: the server starts first, with the page of round 2. - const [third] = await build.server(ENGINE) - await build.clientStart() - expect(third).toContain('/assets/index-def.js') - expect(build.warnings).toHaveLength(3) - expect(build.warnings[2]).toContain('may be stale') - expect(build.warnings[2]).toContain('In watch mode') + it('waits for the first client round once the client watcher exists', async () => { + for (const how of ['build', 'isBuilt']) { + const build = watchApp() + if (how === 'build') { + await startClientWatcher(build) + } else { + await build.plugin.buildApp.handler.call( + {}, + { environments: { client: { isBuilt: true } }, build: async () => ({}) }, + ) + } + const [code] = await build.server(ENGINE) + const rendered = build.render(code!) + expect(await settles(rendered)).toBe(false) + await build.clientStart() + await build.clientBundle(page(NEXT)) + expect(await rendered).toContain(filled(NEXT)) + expect(build.warnings).toEqual([]) + } + }) + + it('does not wait without a client watcher', async () => { + // A custom buildApp that builds only the server. + const root = tempRoot() + const build = watchApp(root) + const [code] = await build.server(ENGINE) + expect(await build.render(code!)).toContain('/src/main.ts') + expect(build.warnings).toEqual([ + `built client index.html not found at ${join(root, 'dist/client/index.html')}; ` + + `embedding the source ${join(root, 'index.html')}, so SSR pages will load the source entry.`, + ]) + + const built = tempRoot(BUILT) + const earlier = watchApp(built) + const [again] = await earlier.server(ENGINE) + expect(await earlier.render(again!)).toContain(filled(BUILT)) + expect(earlier.warnings).toEqual([ + `${join(built, 'dist/client/index.html')} comes from an earlier client build and may be stale.`, + ]) + }) + + it('does not block the server when a client round fails', async () => { + // An error in another plugin's generateBundle shows only as the watcher's ERROR event. + for (const how of ['buildEnd', 'renderError', 'ERROR event']) { + const build = watchApp() + const emit = await startClientWatcher(build) + await build.clientStart() + await build.clientBundle(page(BUILT)) + + await build.clientStart() + const [code] = await build.server(ENGINE) + const rendered = build.render(code!) + expect(await settles(rendered)).toBe(false) + if (how === 'buildEnd') build.clientEnd(new Error('client')) + else if (how === 'renderError') build.clientRenderError() + else emit('ERROR') + expect(await rendered).toContain(filled(BUILT)) + expect(build.warnings).toEqual([ + 'the client build failed, so the SSR page comes from its previous build and may be stale.', + ]) + } + }) + + it('rebuilds the server through a watched file when the client page changes', async () => { + // Rolldown never watches a path that is missing when the build ends, so + // the client's index.html in a fresh outDir would never rebuild it. + const build = watchApp() + await startClientWatcher(build) + + // Round 1 of a fresh outDir: the client fails, so the server embeds the source page. + await build.clientStart() + const [code] = await build.server(ENGINE) + expect(build.watched).toHaveLength(1) + const [stamp] = build.watched + stamps.push(stamp) + expect(stamp.endsWith('.stamp')).toBe(true) + expect(readFileSync(stamp, 'utf-8')).toBe('') + const rendered = build.render(code!) + build.clientEnd(new Error('client')) + expect(await rendered).toContain('/src/main.ts') + + // A client-only fix: the server embedded another page, so the client writes the file. + await build.clientStart() + await build.clientBundle(page(BUILT)) + expect(readFileSync(stamp, 'utf-8')).not.toBe('') + + // The rebuilt server takes the page, and the same page again writes nothing. + writeFileSync(stamp, 'seen') + const [again] = await build.server(ENGINE) + expect(build.watched).toEqual([stamp, stamp]) + expect(await build.render(again!)).toContain(filled(BUILT)) + await build.clientStart() + await build.clientBundle(page(BUILT)) + expect(readFileSync(stamp, 'utf-8')).toBe('seen') + + // Outside watch mode nothing is watched. + const once = app(tempRoot(), { builder: true }) + await once.server(ENGINE) + expect(once.watched).toEqual([]) + }) + + it('takes the page only from the environment named client', async () => { + const build = watchApp() + await build.clientStart() + const [code] = await build.server(ENGINE) + const rendered = build.render(code!) + // Another client environment (a worker or a second browser build). + await build.clientBundle(page(NEXT), 'legacy') + expect(await settles(rendered)).toBe(false) + await build.clientBundle(page(BUILT)) + expect(await rendered).toContain(filled(BUILT)) + }) + + it('takes the page of a client that does not watch', async () => { + // Only `environments.ssr.build.watch` is set: the client builds once, first. + const build = app(tempRoot(), { builder: true, watch: ['ssr'] }) + await build.clientStart() + await build.clientBundle(page(BUILT)) + const [code] = await build.server(ENGINE) + expect(await build.render(code!)).toContain(filled(BUILT)) + expect(build.warnings).toEqual([]) + }) + + it('fills in one line with the same text as outside watch mode', async () => { + const PAGE = '\r\n`${x}\\\r\n' + const build = watchApp() + await build.clientStart() + const [code] = await build.server(ENGINE) + const rendered = build.render(code!) + await build.clientBundle(page(PAGE)) + const out = (await rendered)! + + const literal = (c: string) => + c.slice( + c.indexOf('Promise.resolve(') + 'Promise.resolve('.length, + c.indexOf('),\n },\n },'), + ) + expect(literal(out)).toBe('`\\n\\`\\${x}\\\\\\n\\n`') + const outside = generateAppManifestCode({ + ssrEntryImport: './main.server', + baseHref: '/', + indexHtmlContent: PAGE, + }) + expect(runInNewContext(literal(out))).toBe(runInNewContext(literal(outside))) + expect(out).toContain('size: 36,') + expect(outside).toContain('size: 36,') + }) }) it('embeds the page of this round without a warning outside watch mode', async () => { @@ -371,6 +574,7 @@ describe('SSR Manifest Generation (Issue #60)', () => { build.clientBundle(page(BUILT)) const [code] = await build.server(ENGINE) expect(code).toContain('/assets/index-abc.js') + expect(code).not.toContain('__oxc_angular') expect(build.warnings).toEqual([]) }) diff --git a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts index 861c7515f..ce974cdf5 100644 --- a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts @@ -14,10 +14,13 @@ * @see https://github.com/voidzero-dev/oxc-angular-compiler/issues/60 */ -import { readFile } from 'node:fs/promises' -import { dirname, relative, resolve } from 'node:path' +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' -import type { Plugin, ResolvedConfig, Rolldown } from 'vite' +import type { Plugin, ResolvedConfig, Rolldown, ViteBuilder } from 'vite' import { normalizePath } from 'vite' /** @@ -109,6 +112,123 @@ export interface SsrManifestPluginOptions { ssrEntry?: string } +// In `vite build --app --watch`, the server's manifest holds these free +// identifiers until its renderChunk fills in the page of the client round. +const PAGE_TOKEN = '__oxc_angular_index_server_html__' +const PAGE_SIZE_TOKEN = '__oxc_angular_index_server_html_size__' + +const MINIMAL_PAGE = '' + +type Environment = NonNullable +type EnvironmentConfig = Environment['config'] + +/** One build of the `client` environment, while the server may wait for its page. */ +interface ClientRound { + promise: Promise + resolve: () => void + /** The round ended with an error, so it built no page. */ + failed?: boolean +} + +/** What a watched server environment embedded, to know when it must rebuild. */ +interface ServerState { + /** The page its last round embedded. */ + page?: string + /** Its renderChunk calls that wait for a client round. */ + waiting: number + /** It gave the warning of this round. */ + warned: boolean +} + +function newClientRound(): ClientRound { + let resolve!: () => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +function sourceText(source: string | Uint8Array): string { + return typeof source === 'string' ? source : Buffer.from(source).toString() +} + +function isBuilder(environment: Environment): boolean { + return !!environment.getTopLevelConfig?.().builder +} + +function isWatch(environment: Environment, meta: Rolldown.PluginContextMeta | undefined): boolean { + return !!(meta?.watchMode || environment.config.build?.watch) +} + +/** + * A server environment of `vite build --app --watch`. The client and server + * environments build at the same time, so its page is filled in at render time. + */ +function isAppWatchServer( + environment: Environment, + meta: Rolldown.PluginContextMeta | undefined, +): boolean { + return ( + environment.config.consumer === 'server' && + isWatch(environment, meta) && + isBuilder(environment) && + !!environment.config.environments?.client + ) +} + +/** The `client` environment of `vite build --app`, the one whose page the server embeds. */ +function isAppClient(environment: Environment): boolean { + return ( + environment.name === 'client' && + environment.config.consumer === 'client' && + isBuilder(environment) + ) +} + +/** + * The page to embed when the client built none in this run: the client + * outDir's index.html from an earlier run, the source index.html, or a + * minimal page. `warning` says why the page may be wrong. + */ +async function fallbackPage( + config: EnvironmentConfig, + builder: boolean, + advice: string, +): Promise<{ page: string; warning?: string }> { + // A client built by an earlier run. buildStart runs before Vite empties outDir. + const clientOutDir = config.environments?.client?.build?.outDir + const builtPath = clientOutDir && resolve(config.root, clientOutDir, 'index.html') + if (builtPath) { + try { + const page = await readFile(builtPath, 'utf-8') + return { + page, + warning: builder + ? `${builtPath} comes from an earlier client build and may be stale.` + advice + : undefined, + } + } catch { + // Not built yet. + } + } + + const sourcePath = resolve(config.root, 'index.html') + try { + const page = await readFile(sourcePath, 'utf-8') + return { + page, + warning: + `built client index.html not found${builtPath ? ` at ${builtPath}` : ''}; ` + + `embedding the source ${sourcePath}, so SSR pages will load the source entry.` + + (builder + ? advice + : ' Build the client first (`vite build --app`) or set ' + + '`environments.client.build.outDir`.'), + } + } catch { + // index.html not found, provide a minimal fallback + return { page: MINIMAL_PAGE } + } +} + /** * Vite plugin that generates Angular SSR manifests for AngularNodeAppEngine. */ @@ -121,6 +241,43 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { // The fallback warning of each server environment, emitted once it injects the manifest. const fallbackWarnings = new Map() + // `vite build --app --watch` (#705). Like the Angular CLI, each server round + // must embed the page of the same client round, but the two environments + // build at the same time. So the server waits for that round in renderChunk. + + // The page the `client` environment built last in this run. + let clientPage: string | undefined + // The client round in progress. + let clientRound: ClientRound | undefined + // The first client round ended (with or without a page). + let firstRoundDone = false + const firstRound = newClientRound() + let appBuilder: ViteBuilder | undefined + // `builder.build` was called for the client, so its first round will come. + let clientBuildStarted = false + // A file each watched server environment watches. Rolldown has no call that + // starts a rebuild, so the client writes this file when a server needs its page. + let stampPath: string | undefined + const servers = new Map() + + const serverState = (name: string): ServerState => { + let state = servers.get(name) + if (!state) servers.set(name, (state = { waiting: 0, warned: false })) + return state + } + + const endClientRound = (failed: boolean) => { + if (clientRound) { + clientRound.failed = failed + clientRound.resolve() + clientRound = undefined + } + if (!firstRoundDone) { + firstRoundDone = true + firstRound.resolve() + } + } + return { name: '@oxc-angular/vite-ssr-manifest', apply: 'build', @@ -139,90 +296,137 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { : resolve(workspaceRoot, 'src/main.server.ts') }, + buildApp: { + order: 'pre', + async handler(builder) { + appBuilder = builder + // In watch mode `build` returns the client's watcher. A round that fails + // in a hook this plugin does not see (another plugin's generateBundle) + // only shows up as an ERROR event, and the server must not wait forever. + const build = builder.build.bind(builder) + builder.build = async (environment) => { + const client = environment.name === 'client' + if (client) clientBuildStarted = true + const output = await build(environment) + if (client && output && 'on' in output && typeof output.on === 'function') { + output.on('event', (event) => { + if (event.code === 'ERROR') endClientRound(true) + }) + } + return output + } + }, + }, + generateBundle: { // 'post': vite:build-html adds index.html to the bundle in its own generateBundle. order: 'post', - handler(_, bundle) { + async handler(_, bundle) { if (this.environment?.config.consumer !== 'client') return const asset = bundle['index.html'] if (asset?.type === 'asset') clientIndexHtml = asset + if (!isAppClient(this.environment)) return + + if (asset?.type === 'asset') clientPage = sourceText(asset.source) + // A watched server that embedded another page, and does not wait for + // this one, must rebuild. + const page = clientPage + const rebuild = + page !== undefined && + [...servers.values()].some( + (server) => server.page !== undefined && server.waiting === 0 && server.page !== page, + ) + endClientRound(false) + if (rebuild && stampPath) { + try { + await writeFile(stampPath, `${Date.now()}\n`) + } catch (error) { + this.warn( + `could not write ${stampPath}, so the server does not rebuild: ${String(error)}`, + ) + } + } }, }, - async buildStart() { - // `environment` is only set by Vite, not by plain rolldown / tsdown. - if (!this.environment) return - const config = this.environment.config - const watch = !!(this.meta?.watchMode || config.build?.watch) - if (config.consumer === 'client') { - // A watch round must not reuse the page of the previous round. - if (watch) clientIndexHtml = undefined - return + buildEnd(error) { + if (error && this.environment && isAppClient(this.environment)) endClientRound(true) + }, + + renderError() { + if (this.environment && isAppClient(this.environment)) endClientRound(true) + }, + + closeBundle() { + // Outside watch mode, a failed generateBundle still closes the bundle. In + // watch mode the watcher's ERROR event ends the round. + const environment = this.environment + if (environment && isAppClient(environment) && !isWatch(environment, this.meta)) { + endClientRound(true) } - if (config.consumer !== 'server') return - const name = this.environment.name - fallbackWarnings.delete(name) - - // `vite build --app` builds the environments in `environments` order, one - // after another, but in watch mode at the same time, so the client's page - // can be the one of its previous round. - const builder = !!this.environment.getTopLevelConfig?.().builder - const names = Object.keys(config.environments ?? {}) - const clientFirst = names.indexOf('client') < names.indexOf(name) - const advice = - (builder && watch - ? ' In watch mode the client and server environments build at the same time, ' + - 'so the SSR page may come from the previous client round or the source page.' - : '') + (builder && !clientFirst ? ' List `client` first in `environments`.' : '') - - // Like the Angular CLI, serve the built browser page, which loads the - // hashed bundles, not the source one (@angular/build - // src/utils/index-file/index-html-generator.ts builds index.server.html - // from the augmented page). - if (clientIndexHtml) { - const { source } = clientIndexHtml - indexHtmlContent = typeof source === 'string' ? source : Buffer.from(source).toString() - if (builder && watch) { - fallbackWarnings.set(name, 'the built client index.html may be stale.' + advice) + }, + + buildStart: { + // 'pre': open the client round before slower buildStart hooks run. + order: 'pre', + async handler() { + // `environment` is only set by Vite, not by plain rolldown / tsdown. + if (!this.environment) return + const config = this.environment.config + if (config.consumer === 'client') { + if (isAppClient(this.environment) && !clientRound) clientRound = newClientRound() + // A watch round must not reuse the page of the previous round. + if (isWatch(this.environment, this.meta)) clientIndexHtml = undefined + return } - return - } + if (config.consumer !== 'server') return + const name = this.environment.name + fallbackWarnings.delete(name) - // A client built by an earlier run. buildStart runs before Vite empties outDir. - const clientOutDir = config.environments?.client?.build?.outDir - const builtPath = clientOutDir && resolve(config.root, clientOutDir, 'index.html') - if (builtPath) { - try { - indexHtmlContent = await readFile(builtPath, 'utf-8') - if (builder) { - fallbackWarnings.set( - name, - `${builtPath} comes from an earlier client build and may be stale.` + advice, - ) + if (isAppWatchServer(this.environment, this.meta)) { + // The page comes in renderChunk. Watch a file that exists before this + // build ends (rolldown never watches a path that was missing then), + // so a client-only change can rebuild the server. It lives in the + // temp dir, by its real path: rolldown does not watch files in + // node_modules (Vite's cacheDir), and on macOS it missed changes to a + // file under the /var symlink of the temp dir. + serverState(name).warned = false + const clientOutDir = resolve(config.root, config.environments.client.build?.outDir ?? '') + const hash = createHash('sha256').update(clientOutDir).digest('hex').slice(0, 16) + try { + stampPath = join(realpathSync(tmpdir()), 'oxc-angular-ssr', `${hash}.stamp`) + await mkdir(dirname(stampPath), { recursive: true }) + await writeFile(stampPath, '', { flag: 'wx' }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + this.warn( + `could not write ${stampPath ?? tmpdir()}, so client-only changes do not rebuild the server: ${String(error)}`, + ) + } } + if (stampPath) this.addWatchFile(stampPath) return - } catch { - // Not built yet. } - } - const sourcePath = resolve(config.root, 'index.html') - try { - indexHtmlContent = await readFile(sourcePath, 'utf-8') - fallbackWarnings.set( - name, - `built client index.html not found${builtPath ? ` at ${builtPath}` : ''}; ` + - `embedding the source ${sourcePath}, so SSR pages will load the source entry.` + - (builder - ? advice - : ' Build the client first (`vite build --app`) or set ' + - '`environments.client.build.outDir`.'), - ) - } catch { - // index.html not found, provide a minimal fallback - indexHtmlContent = - '' - } + // `vite build --app` builds the environments in `environments` order. + const builder = isBuilder(this.environment) + const names = Object.keys(config.environments ?? {}) + const clientFirst = names.indexOf('client') < names.indexOf(name) + const advice = builder && !clientFirst ? ' List `client` first in `environments`.' : '' + + // Like the Angular CLI, serve the built browser page, which loads the + // hashed bundles, not the source one (@angular/build + // src/utils/index-file/index-html-generator.ts builds index.server.html + // from the augmented page). + if (clientIndexHtml) { + indexHtmlContent = sourceText(clientIndexHtml.source) + return + } + + const fallback = await fallbackPage(config, builder, advice) + indexHtmlContent = fallback.page + if (fallback.warning) fallbackWarnings.set(name, fallback.warning) + }, }, transform(code, id) { @@ -247,11 +451,16 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { ssrEntryImport = './' + ssrEntryImport } - const appManifest = generateAppManifestCode({ - ssrEntryImport, - baseHref, - indexHtmlContent: indexHtmlContent || '', - }) + // In `vite build --app --watch`, renderChunk fills in the page. + const appManifest = isAppWatchServer(this.environment, this.meta) + ? generateAppManifestCode({ ssrEntryImport, baseHref, indexHtmlContent: '' }) + .replace('size: 0,', `size: ${PAGE_SIZE_TOKEN},`) + .replace('Promise.resolve(``)', `Promise.resolve(${PAGE_TOKEN})`) + : generateAppManifestCode({ + ssrEntryImport, + baseHref, + indexHtmlContent: indexHtmlContent || '', + }) const engineManifest = generateAppEngineManifestCode({ basePath: baseHref, @@ -270,5 +479,54 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { } } }, + + async renderChunk(code) { + const environment = this.environment + if (environment?.config.consumer !== 'server' || !code.includes(PAGE_TOKEN)) return null + const server = serverState(environment.name) + + // Wait for the client round in progress, or for the first one when the + // client watcher has started but its first round has not begun yet. + let round: ClientRound | undefined + server.waiting++ + try { + if (clientRound) { + round = clientRound + await round.promise + } else if ( + !firstRoundDone && + (clientBuildStarted || appBuilder?.environments.client?.isBuilt) + ) { + await firstRound.promise + } + } finally { + server.waiting-- + } + + let page = clientPage + let warning: string | undefined + if (page === undefined) { + ;({ page, warning } = await fallbackPage(environment.config, true, '')) + } else if (round?.failed) { + warning = + 'the client build failed, so the SSR page comes from its previous build and may be stale.' + } + server.page = page + if (warning && !server.warned) { + server.warned = true + this.warn(warning) + } + + // One line, so the code after it keeps its line in the source map. A + // template literal reads CRLF and CR as LF, so the text is the same as + // the multi-line literal outside watch mode. + const literal = '`' + escapeUnsafeChars(page).replace(/\r\n?|\n/g, '\\n') + '`' + return { + code: code + .replaceAll(PAGE_SIZE_TOKEN, String(Buffer.byteLength(page, 'utf-8'))) + .replaceAll(PAGE_TOKEN, () => literal), + map: null, + } + }, } } From 0ffb1071cccfaf97bd657c4f353b73029b390ecd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:10:36 +0800 Subject: [PATCH 11/22] fix(vite): keep line breaks and maps as tsc wrote them in augmentDeclarations (#706) - Take the line break from the last one in the file and give it only to the lines the splice adds, so line breaks inside template literal types stay as tsc copied them from the source. - A CRLF file on disk is no longer taken as the same text as tsc's LF emit, so newLine given only on the tsc command line keeps CRLF and plain files are left alone. - When tsc's in-memory emit has no map but the file on disk points at one (declarationMap only on the command line), follow the file's comment and shift that map. - Write a map only when its declaration file changed, and keep the map tsc wrote when the file is tsc's text, so file URL sources under mapRoot keep tsc's spelling. - Tests compare paths with "/" so they pass on Windows. Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/README.md | 4 + .../angular-compiler/test/dts-augment.test.ts | 116 ++++++++++++++++-- .../vite-plugin/dts-augment.ts | 63 +++++++--- 3 files changed, 157 insertions(+), 26 deletions(-) diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 98b18c5d6..79f460cf0 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -369,6 +369,10 @@ tsconfig form, with paths relative to `cwd`: await augmentDeclarations({ tsconfig: 'tsconfig.json', compilerOptions: { outDir: 'types' } }) ``` +When such an option is left out but the file is still found, it is patched +as `tsc` wrote it (its line breaks and the map its comment points at are +kept), but members from an earlier run are then kept as they are. + Without TypeScript, pass the output dir and the library's source files instead. Each declaration file is then matched to its source as in the Vite build: by its declaration map, else by the one layout of the dir. A file that diff --git a/napi/angular-compiler/test/dts-augment.test.ts b/napi/angular-compiler/test/dts-augment.test.ts index 285926fd9..c78e3d616 100644 --- a/napi/angular-compiler/test/dts-augment.test.ts +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -12,7 +12,7 @@ import { } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -197,13 +197,18 @@ function read(file: string): string { return readFileSync(file, 'utf8') } +/** The path of `file` from `dir`, with `/` on every platform. */ +function rel(dir: string, file: string): string { + return relative(dir, file).split(sep).join('/') +} + /** Every file under `dir`, by path from `dir`, with its text. */ function snapshot(dir: string): Record { const out: Record = {} for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { if (!entry.isFile()) continue const file = join(entry.parentPath, entry.name) - out[relative(dir, file)] = read(file) + out[rel(dir, file)] = read(file) } return out } @@ -235,7 +240,7 @@ describe('augmentDeclarations with a tsconfig', () => { expect(result.errors).toEqual([]) expect(result.missing).toEqual([]) expect(result.unmatched).toEqual([]) - expect(result.written.map((f) => relative(dist, f)).sort()).toEqual(ANGULAR_FILES) + expect(result.written.map((f) => rel(dist, f)).sort()).toEqual(ANGULAR_FILES) expect(result.matched).toHaveLength(Object.keys(NGC).length) for (const [file, text] of Object.entries(NGC)) { expect(read(join(dist, file)), file).toBe(text) @@ -294,7 +299,7 @@ describe('augmentDeclarations with a tsconfig', () => { declarationDir: 'dd', cwd: dir, }) - expect(byDir.written.map((f) => relative(join(dir, 'dd'), f)).sort()).toEqual(ANGULAR_FILES) + expect(byDir.written.map((f) => rel(join(dir, 'dd'), f)).sort()).toEqual(ANGULAR_FILES) }) it('keeps CRLF line endings', async () => { @@ -314,6 +319,92 @@ describe('augmentDeclarations with a tsconfig', () => { expect(mappings(join(dir, 'dist/lib/bar.directive.d.ts.map'))).toBe(AUGMENTED_BAR_MAPPINGS) }) + it('keeps CRLF when newLine was only given to tsc, and leaves plain files alone', async () => { + const dir = project('crlf-cli') + tsc(dir, '-p', 'tsconfig.json', '--newLine', 'crlf') + const dist = join(dir, 'dist') + const plain = snapshot(dist) + + const result = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + + expect(result.errors).toEqual([]) + expect(result.written.map((f) => rel(dist, f)).sort()).toEqual(ANGULAR_FILES) + for (const [file, text] of Object.entries(NGC)) { + expect(read(join(dist, file)), file).toBe(text.replace(/\n/g, '\r\n')) + } + expect(read(join(dist, 'lib/util.d.ts'))).toBe(plain['lib/util.d.ts']) + expect(mappings(join(dist, 'lib/bar.directive.d.ts.map'))).toBe(AUGMENTED_BAR_MAPPINGS) + }) + + it('keeps the line breaks inside template literal types as they are', async () => { + const dir = project('template-literal') + // tsc copies these line breaks from the source: `\r\n` in one file, + // `\n` in the other, whatever `newLine` is. + writeFileSync( + join(dir, 'src/lib/crlf.ts'), + [ + 'import { Directive } from "@angular/core";', + 'export type W = `x\r\ny`;', + '@Directive({ selector: "[libW]" })', + 'export class WDir {}', + '', + ].join('\r\n'), + ) + writeFileSync( + join(dir, 'src/lib/lf.ts'), + "import { Directive } from '@angular/core';\nexport type T = `x\ny`;\n" + + "@Directive({ selector: '[libT]' })\nexport class TDir {}\n", + ) + writeFileSync( + join(dir, 'src/public-api.ts'), + "export * from './lib/crlf';\nexport * from './lib/lf';\n", + ) + tsc(dir, '-p', 'tsconfig.json') + tsc(dir, '-p', 'tsconfig.json', '--newLine', 'crlf', '--outDir', 'dist-crlf') + + await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + await augmentDeclarations({ + tsconfig: join(dir, 'tsconfig.json'), + compilerOptions: { newLine: 'crlf', outDir: 'dist-crlf' }, + cwd: dir, + }) + + // What ngc writes, with `\r\n` and `\n` in the template literal types. + const ngc = (name: string, selector: string, type: string, file: string) => [ + 'import * as i0 from "@angular/core";', + type, + `export declare class ${name} {`, + ` static ɵfac: i0.ɵɵFactoryDeclaration<${name}, never>;`, + ` static ɵdir: i0.ɵɵDirectiveDeclaration<${name}, "${selector}", never, {}, {}, never, never, true, never>;`, + '}', + `//# sourceMappingURL=${file}.d.ts.map`, + ] + const w = ngc('WDir', '[libW]', 'export type W = `x\r\ny`;', 'crlf') + const t = ngc('TDir', '[libT]', 'export type T = `x\ny`;', 'lf') + expect(read(join(dir, 'dist/lib/crlf.d.ts'))).toBe(w.join('\n')) + expect(read(join(dir, 'dist/lib/lf.d.ts'))).toBe(t.join('\n')) + expect(read(join(dir, 'dist-crlf/lib/crlf.d.ts'))).toBe(w.join('\r\n')) + expect(read(join(dir, 'dist-crlf/lib/lf.d.ts'))).toBe(t.join('\r\n')) + }) + + it('shifts the map when declarationMap was only given to tsc', async () => { + const dir = project('map-cli') + const { declarationMap, ...rest } = TSCONFIG.compilerOptions + expect(declarationMap).toBe(true) + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ ...TSCONFIG, compilerOptions: rest }, null, 2), + ) + tsc(dir, '-p', 'tsconfig.json', '--declarationMap') + const map = join(dir, 'dist/lib/bar.directive.d.ts.map') + expect(mappings(map)).toBe(TSC_BAR_MAPPINGS) + + await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) + + expect(read(join(dir, 'dist/lib/bar.directive.d.ts'))).toBe(NGC['lib/bar.directive.d.ts']) + expect(mappings(map)).toBe(AUGMENTED_BAR_MAPPINGS) + }) + it('keeps the byte order mark of emitBOM', async () => { const dir = project('bom') tsc(dir, '-p', 'tsconfig.json', '--emitBOM') @@ -334,11 +425,22 @@ describe('augmentDeclarations with a tsconfig', () => { it('shifts the map when mapRoot makes the map comment a URL', async () => { const dir = project('map-root') tsc(dir, '-p', 'tsconfig.json', '--mapRoot', 'https://example.com/maps/') + const plain = snapshot(join(dir, 'dist')) + // With `mapRoot` the maps name their sources by absolute file URL, and + // `tsc` saw the real path. A path through a link gives other URLs, so + // the maps `tsc` wrote must be kept. + const link = join(root, 'map-root-link') + symlinkSync(dir, link, 'junction') await augmentDeclarations({ - tsconfig: join(dir, 'tsconfig.json'), + tsconfig: join(link, 'tsconfig.json'), compilerOptions: { mapRoot: 'https://example.com/maps/' }, }) + expect(read(join(dir, 'dist/lib/util.d.ts.map'))).toBe(plain['lib/util.d.ts.map']) + const sources = (file: string) => JSON.parse(file).sources + expect(sources(read(join(dir, 'dist/lib/bar.directive.d.ts.map')))).toEqual( + sources(plain['lib/bar.directive.d.ts.map']), + ) const bar = read(join(dir, 'dist/lib/bar.directive.d.ts')) expect(bar).toBe( @@ -386,7 +488,7 @@ describe('augmentDeclarations with a tsconfig', () => { const result = await augmentDeclarations({ tsconfig: join(dir, 'tsconfig.json') }) expect(result.written).toEqual([]) expect(result.matched).toEqual([]) - expect(result.missing.map((f) => relative(join(dir, 'dist'), f)).sort()).toEqual( + expect(result.missing.map((f) => rel(join(dir, 'dist'), f)).sort()).toEqual( Object.keys(NGC).sort(), ) }) @@ -423,7 +525,7 @@ describe('augmentDeclarations with sources', () => { expect(result.errors).toEqual([]) expect(result.unmatched).toEqual([stray]) - expect(result.written.map((f) => relative(join(dir, 'dist'), f)).sort()).toEqual(ANGULAR_FILES) + expect(result.written.map((f) => rel(join(dir, 'dist'), f)).sort()).toEqual(ANGULAR_FILES) for (const [file, text] of Object.entries(NGC)) { expect(read(join(dir, 'dist', file)), file).toBe( text.replace(/\n\/\/# sourceMappingURL=.*$/, '\n'), diff --git a/napi/angular-compiler/vite-plugin/dts-augment.ts b/napi/angular-compiler/vite-plugin/dts-augment.ts index 00d086905..e18abd27a 100644 --- a/napi/angular-compiler/vite-plugin/dts-augment.ts +++ b/napi/angular-compiler/vite-plugin/dts-augment.ts @@ -364,28 +364,44 @@ async function augmentTarget( let base = disk let mapFile: string | undefined let baseMap: string | undefined + // Whether `base` is the text `tsc` writes for these options. + let fresh = false if (emitted) { mapFile = emitted.mapFile - if (disk === emitted.code || sameApartFromIvyMembers(disk, emitted.code)) { + if (disk === emitted.code) { + // Keep the map `tsc` wrote: its paths are spelled as `tsc` saw them. + fresh = true + baseMap = (mapFile && (await readText(mapFile))) ?? emitted.map + } else if (sameApartFromIvyMembers(disk, emitted.code)) { + fresh = true base = emitted.code baseMap = emitted.map - } else { - baseMap = mapFile && (await readText(mapFile)) + } else if (mapFile) { + baseMap = await readText(mapFile) } } - // tsc writes `\r\n` with `newLine: "crlf"`; the splice works on `\n` - // lines. Lines stay where they are, so the map is the same either way. + // The splice works on `\n` lines. tsc ends lines with `\r\n` under + // `newLine: "crlf"`, but copies the line breaks inside a template literal + // type from the source as they are. So the line break tsc used is the + // last one in the file, and only the lines the splice adds get it. Lines + // stay where they are, so the map is the same either way. const bom = base.startsWith(BOM) ? BOM : '' - const crlf = base.includes('\r\n') let code = base.slice(bom.length) - if (crlf) code = code.replace(/\r\n/g, '\n') + const lastBreak = code.lastIndexOf('\n') + const crlf = lastBreak > 0 && code[lastBreak - 1] === '\r' + // Marks a `\n` that is not part of a `\r\n`, so it stays a lone `\n`. + const mark = crlf && !code.includes(LONE_LF_MARK) + if (crlf) { + if (mark) code = code.replace(/(? 0) { - // With a known map file (`mapRoot` makes the comment a URL), use it. - const readMap = emitted - ? () => baseMap - : (ref: string) => readTextSync(resolve(dirname(file), ref)) + // With a known map file (`mapRoot` makes the comment a URL), use it; + // else follow the file's own `//# sourceMappingURL=` comment. + const readMap = (ref: string) => + fresh || mapFile ? baseMap : readTextSync(resolve(dirname(file), ref)) const injected = injectDtsFile(code, declarations, file, readMap) if (injected) { code = injected.code @@ -395,13 +411,17 @@ async function augmentTarget( } } } - if (crlf) code = code.replace(/\n/g, '\r\n') + if (crlf) { + code = mark + ? code.replace(/(? text - .replace(/\r\n/g, '\n') .split('\n') .filter((line) => !IVY_MEMBER_LINE.test(line) && !NAMESPACE_IMPORT_LINE.test(line)) .join('\n') From d2001c7f6c9c141a8e8e5c9cb80a4ff166d0adc6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:29:30 +0800 Subject: [PATCH 12/22] fix: escape non-ASCII strings like ngtsc and match each linker's changeDetection default (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strings the compiler creates are now printed the way ngtsc's TypeScript printer prints them: every character outside ASCII becomes a \uXXXX escape, one per UTF-16 unit, with upper-case hex. Strings copied from the source keep their source text, as ngtsc does: providers, setClassMetadata arguments and keys, @Attribute and @Inject string tokens, the NgModule id, template literals, and the inline template in partial output. The .d.ts strings and added import lines are escaped too. The linker follows the Angular linker and its Babel 8 generator instead: it prints raw UTF-8 and escapes only the Unicode spaces (\xA0, \u2028, \u2029, ...). Input and output names and exportAs are rebuilt as new strings, as the Angular linker reads them with getString(); host attribute values keep their source text. A partial @Attribute token is now a string literal ('é'), not a bare name. The linker no longer writes changeDetection: 1 for a component with no changeDetection field when the app uses Angular 19, 20 or 21. Those linkers default the field to Default and leave it out of the output. The v22 linker still writes 1 for declarations made before v22. Co-Authored-By: Claude Opus 5.5 --- .../src/component/decorator.rs | 17 +- .../src/component/dependency.rs | 48 ++- .../src/component/namespace_registry.rs | 2 +- .../src/component/transform.rs | 14 + .../src/directive/decorator.rs | 9 +- crates/oxc_angular_compiler/src/dts.rs | 10 + crates/oxc_angular_compiler/src/dts_inject.rs | 5 +- .../src/injectable/decorator.rs | 9 +- crates/oxc_angular_compiler/src/linker/mod.rs | 136 ++++-- .../src/ng_module/decorator.rs | 33 +- crates/oxc_angular_compiler/src/output/ast.rs | 14 +- .../src/output/emitter.rs | 236 ++++++---- .../src/output/oxc_converter.rs | 94 +++- .../src/partial/component.rs | 10 +- .../src/pipe/decorator.rs | 9 +- .../oxc_angular_compiler/src/pipeline/emit.rs | 16 +- .../src/pipeline/phases/chaining.rs | 1 + .../pipeline/phases/reify/ir_expression.rs | 16 +- .../tests/integration_test.rs | 149 +++---- .../oxc_angular_compiler/tests/linker_test.rs | 7 +- .../tests/lone_surrogate_names_test.rs | 13 +- .../tests/non_ascii_output_test.rs | 404 ++++++++++++++++++ 22 files changed, 1005 insertions(+), 247 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/non_ascii_output_test.rs diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index fbf201915..efc70b6f5 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -925,6 +925,10 @@ fn extract_param_dependency<'a>( let mut inject_token: Option<(Ident<'a>, &'a [Ident<'a>])> = None; let mut inject_token_is_string = false; let mut attribute_name: Option> = None; + // The string token or attribute name as written, when ngtsc keeps it + // (see `string_literal_as_written`). + let mut inject_literal_source: Option> = None; + let mut attribute_source: Option> = None; for decorator in ¶m.decorators { if let Some(name) = crate::directive::angular_param_decorator(decorator, Some(consts)) { @@ -938,6 +942,12 @@ fn extract_param_dependency<'a>( let string_token = arg.as_expression().and_then(extract_string_inject_token); inject_token_is_string = string_token.is_some(); + inject_literal_source = arg.as_expression().and_then(|expr| { + crate::output::oxc_converter::literal_as_written( + allocator, + expr.get_inner_expression(), + ) + }); inject_token = string_token .map(|token| (token, &[][..])) .or_else(|| extract_inject_token(allocator, arg)); @@ -955,6 +965,8 @@ fn extract_param_dependency<'a>( && let Some(name) = s.value.as_str() { attribute_name = Some(name.into()); + attribute_source = + crate::output::oxc_converter::string_literal_as_written(s); } } } @@ -973,12 +985,15 @@ fn extract_param_dependency<'a>( // Handle @Attribute decorator if let Some(attr_name) = attribute_name { - return R3DependencyMetadata::attribute(attr_name); + let mut dep = R3DependencyMetadata::attribute(attr_name); + dep.literal_source = attribute_source; + return dep; } if inject_token_is_string && let Some(value) = token { let mut dep = R3DependencyMetadata::new(value); dep.token_is_string = true; + dep.literal_source = inject_literal_source; dep.optional = optional; dep.skip_self = skip_self; dep.self_ = self_; diff --git a/crates/oxc_angular_compiler/src/component/dependency.rs b/crates/oxc_angular_compiler/src/component/dependency.rs index e3575ab87..d88027c8d 100644 --- a/crates/oxc_angular_compiler/src/component/dependency.rs +++ b/crates/oxc_angular_compiler/src/component/dependency.rs @@ -86,6 +86,11 @@ pub struct R3DependencyMetadata<'a> { /// The token is a string literal (`@Inject('TOKEN')`), not a reference. pub token_is_string: bool, + + /// The string token or attribute name as written in the source, quotes + /// included, when it holds text outside ASCII. ngtsc prints such a literal as + /// written, not escaped (see `string_literal_as_written`). + pub literal_source: Option>, } impl<'a> R3DependencyMetadata<'a> { @@ -105,6 +110,7 @@ impl<'a> R3DependencyMetadata<'a> { skip_self: false, type_only_invalid: false, token_is_string: false, + literal_source: None, } } @@ -124,6 +130,7 @@ impl<'a> R3DependencyMetadata<'a> { skip_self: false, type_only_invalid: false, token_is_string: false, + literal_source: None, } } @@ -146,6 +153,7 @@ impl<'a> R3DependencyMetadata<'a> { skip_self: false, type_only_invalid: true, token_is_string: false, + literal_source: None, } } @@ -200,6 +208,7 @@ impl<'a> R3DependencyMetadata<'a> { skip_self: false, type_only_invalid: false, token_is_string: false, + literal_source: None, } } @@ -347,7 +356,11 @@ fn compile_inject_dependency<'a>( // Case 2: @Attribute() dependency if let Some(ref attr_name) = dep.attribute_name { - return create_inject_attribute_call(allocator, core_namespace, attr_name.clone()); + return create_inject_attribute_call( + allocator, + core_namespace, + string_token_expr(allocator, attr_name, dep.literal_source), + ); } // Case 3: Regular token injection @@ -407,12 +420,10 @@ pub fn create_token_expression<'a>( token_name: &Ident<'a>, namespace_registry: &mut NamespaceRegistry<'a>, ) -> OutputExpression<'a> { - if dep.token_is_string { - // `@Inject('TOKEN')` injects the string itself - return OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(token_name.clone()), source_span: None }, - &allocator, - )); + if dep.token_is_string || dep.attribute_name.is_some() { + // `@Inject('TOKEN')` injects the string itself; an `@Attribute('name')` + // token is the attribute name (ngtsc: `{ token: 'name', attribute: true }`). + return string_token_expr(allocator, token_name, dep.literal_source); } if let Some(ref source_module) = dep.token_source_module { // Check if this token has an existing named import that can be reused @@ -487,20 +498,33 @@ fn create_invalid_factory_dep_call<'a>( )) } +/// A string token or attribute name: its source text when ngtsc keeps it as +/// written (`literal_source`), else a string literal. +fn string_token_expr<'a>( + allocator: &'a Allocator, + value: &Ident<'a>, + literal_source: Option>, +) -> OutputExpression<'a> { + match literal_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(value.clone()), source_span: None }, + &allocator, + )), + } +} + /// Create an `ns.ɵɵinjectAttribute(attrName)` call. fn create_inject_attribute_call<'a>( allocator: &'a Allocator, core_namespace: Ident<'a>, - attr_name: Ident<'a>, + attr_name: OutputExpression<'a>, ) -> OutputExpression<'a> { let fn_expr = create_angular_fn_ref(allocator, core_namespace, Identifiers::INJECT_ATTRIBUTE); let mut args = OxcVec::with_capacity_in(1, &allocator); // Attribute name is passed as a string literal - args.push(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(attr_name), source_span: None }, - &allocator, - ))); + args.push(attr_name); OutputExpression::InvokeFunction(Box::new_in( InvokeFunctionExpr { diff --git a/crates/oxc_angular_compiler/src/component/namespace_registry.rs b/crates/oxc_angular_compiler/src/component/namespace_registry.rs index 6b95a48d7..d619760c8 100644 --- a/crates/oxc_angular_compiler/src/component/namespace_registry.rs +++ b/crates/oxc_angular_compiler/src/component/namespace_registry.rs @@ -144,7 +144,7 @@ impl<'a> NamespaceRegistry<'a> { result.push_str("import * as "); result.push_str(alias.as_str()); result.push_str(" from '"); - result.push_str(module_path.as_str()); + result.push_str(&crate::output::emitter::escape_non_ascii(module_path.as_str())); result.push_str("';\n"); } diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index b9b2ba12a..c9890a06a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6281,6 +6281,7 @@ fn compile_component_partial<'a>( allocator: &'a Allocator, core_namespace: &'a str, template: &'a str, + template_source: Option<&'a str>, metadata: &ComponentMetadata<'a>, view_queries: &[R3QueryMetadata<'a>], content_queries: &[R3QueryMetadata<'a>], @@ -6292,6 +6293,7 @@ fn compile_component_partial<'a>( ) -> FullCompilationResult<'a> { let inputs = crate::partial::PartialComponentInputs { template, + template_source, is_inline: metadata.template.is_some(), content_queries, view_queries, @@ -7070,10 +7072,22 @@ fn compile_component_full<'a>( Some((import.key, dependencies)) }) .collect(); + // ngtsc emits an inline string or template literal as the source node + // (`inlineTemplateLiteralExpression`), so a template with text outside + // ASCII keeps its source text rather than being escaped. + let template_source = metadata + .template_span + .filter(|_| { + !template.is_ascii() + && metadata.template.as_ref().map(Ident::as_str) == Some(template) + }) + .and_then(|span| source.get(span.start as usize - 1..span.end as usize + 1)) + .map(|text| &*allocator.alloc_str(text)); let mut result = compile_component_partial( allocator, namespace_registry.angular_core_ns().as_str(), template, + template_source, metadata, &view_queries, &content_queries, diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 7efed583a..f2755e9fc 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -341,6 +341,8 @@ fn extract_param_dependency<'a>( let mut host = false; let mut inject_token: Option> = None; let mut attribute_name: Option> = None; + // The attribute name as written, when ngtsc keeps it (see `string_literal_as_written`). + let mut attribute_source: Option> = None; for decorator in ¶m.decorators { if let Some(name) = super::angular_param_decorator(decorator, Some(consts)) { @@ -365,6 +367,8 @@ fn extract_param_dependency<'a>( && let Some(name) = s.value.as_str() { attribute_name = Some(name.into()); + attribute_source = + crate::output::oxc_converter::string_literal_as_written(s); } } } @@ -390,7 +394,10 @@ fn extract_param_dependency<'a>( // ngtsc's `attributeNameType` is the attribute name itself (di.ts), so it is // set whatever the parameter's type and compiles to `ɵɵinjectAttribute`. return R3DependencyMetadata { - token: Some(name.clone_in(allocator)), + token: Some(match attribute_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => name.clone_in(allocator), + }), attribute_name_type: Some(name), host, optional, diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index 94a711890..a2bc8e3e9 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -840,6 +840,13 @@ fn escape_dts_string(s: &str) -> String { Piece::Char('\n') => out.push_str("\\n"), Piece::Char('\r') => out.push_str("\\r"), Piece::Char('\t') => out.push_str("\\t"), + // Text outside ASCII is escaped per UTF-16 code unit, as the + // TypeScript printer escapes the literal types ngtsc creates. + Piece::Char(c) if !c.is_ascii() => { + for unit in c.encode_utf16(&mut [0u16; 2]) { + let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{unit:04X}")); + } + } Piece::Char(c) => out.push(c), // A stored lone surrogate is printed as its `\uXXXX` escape. Piece::Lone(unit) => { @@ -872,6 +879,9 @@ mod tests { assert_eq!(escape_dts_string("line1\nline2"), "line1\\nline2"); assert_eq!(escape_dts_string("col1\tcol2"), "col1\\tcol2"); assert_eq!(escape_dts_string("a\r\nb"), "a\\r\\nb"); + // Text outside ASCII as ngtsc's declaration printer writes it. + assert_eq!(escape_dts_string("[x=\u{00E9}]"), "[x=\\u00E9]"); + assert_eq!(escape_dts_string("\u{1F600}"), "\\uD83D\\uDE00"); } #[test] diff --git a/crates/oxc_angular_compiler/src/dts_inject.rs b/crates/oxc_angular_compiler/src/dts_inject.rs index 1d831f43b..a0c2981f3 100644 --- a/crates/oxc_angular_compiler/src/dts_inject.rs +++ b/crates/oxc_angular_compiler/src/dts_inject.rs @@ -755,7 +755,10 @@ fn plan_splices(source: &str, declarations: &[DtsInjectDeclaration]) -> Vec<(usi import_entries.sort(); let import_lines: Vec = import_entries .iter() - .map(|(alias, specifier)| format!("import * as {alias} from \"{specifier}\";")) + .map(|(alias, specifier)| { + let specifier = crate::output::emitter::escape_non_ascii(specifier); + format!("import * as {alias} from \"{specifier}\";") + }) .collect(); if !import_lines.is_empty() { diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 60867a9a4..6d5014fcc 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -654,6 +654,8 @@ fn extract_param_dependency<'a>( let mut host = false; let mut inject_token: Option> = None; let mut attribute_name: Option> = None; + // The attribute name as written, when ngtsc keeps it (see `string_literal_as_written`). + let mut attribute_source: Option> = None; for decorator in ¶m.decorators { if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { @@ -678,6 +680,8 @@ fn extract_param_dependency<'a>( && let Some(name) = s.value.as_str() { attribute_name = Some(name.into()); + attribute_source = + crate::output::oxc_converter::string_literal_as_written(s); } } } @@ -703,7 +707,10 @@ fn extract_param_dependency<'a>( // ngtsc's `attributeNameType` is the attribute name itself (di.ts), so it is // set whatever the parameter's type and compiles to `ɵɵinjectAttribute`. return R3DependencyMetadata { - token: Some(name.clone_in(allocator)), + token: Some(match attribute_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => name.clone_in(allocator), + }), attribute_name_type: Some(name), host, optional, diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 4d40adbcc..b7ab76a5b 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -58,7 +58,7 @@ use crate::output::ast::{ StmtModifier, }; use crate::output::emitter::JsEmitter; -use crate::output::emitter::escape_string; +use crate::output::emitter::escape_string_for_linker; use crate::output::emitter::is_left_hand_side_source; use crate::pipeline::constant_pool::{ConstantPool, SharedPool}; use crate::pipeline::emit::declare_pooled_constants_from; @@ -75,7 +75,7 @@ fn needs_object_key_quoting(key: &str) -> bool { /// Quote a property key if it contains unsafe characters (dots or hyphens). fn quote_key(key: &str) -> String { - if needs_object_key_quoting(key) { escape_string(key, false) } else { key.to_string() } + if needs_object_key_quoting(key) { escape_string_for_linker(key) } else { key.to_string() } } /// Partial declaration function names to link. @@ -574,7 +574,7 @@ fn as_static_string<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { /// (`'\uD800'`), which has no `&str`, the literal as written. fn static_string_literal<'a>(expr: &'a Expression<'a>, source: &str) -> Option { if let Some(value) = as_static_string(expr) { - return Some(crate::output::emitter::escape_string(value, false)); + return Some(escape_string_for_linker(value)); } let has_lone_surrogate = match expr { Expression::StringLiteral(lit) => lit.value.has_lone_surrogate(), @@ -994,7 +994,7 @@ fn parse_selector(selector: &str) -> String { let parts: Vec = elements .iter() .map(|el| match el { - R3SelectorElement::String(s) => escape_string(s, false), + R3SelectorElement::String(s) => escape_string_for_linker(s), R3SelectorElement::Flag(f) => f.to_string(), }) .collect(); @@ -1035,25 +1035,25 @@ fn build_host_attrs( PropertyKey::StringLiteral(s) if s.value.as_str() == Some("__proto__") => { continue; } - PropertyKey::StaticIdentifier(ident) => { - crate::output::emitter::escape_string(&ident.name, false) - } + PropertyKey::StaticIdentifier(ident) => escape_string_for_linker(&ident.name), // A lone surrogate (`'\uD800'`) has no `&str`: keep the key // as written. PropertyKey::StringLiteral(s) => match s.value.as_str() { - Some(key) => crate::output::emitter::escape_string(key, false), + Some(key) => escape_string_for_linker(key), None => source[s.span.start as usize..s.span.end as usize].to_string(), }, _ => continue, }; // Static strings (including template literals a bundler may have // emitted) are emitted cooked and quoted like Angular's output. - let value = as_static_string(&p.value).map_or_else( + // Angular's linker copies the value node as is (`getOpaque()`), + // so a value with text outside ASCII keeps its source text. + let value = as_static_string(&p.value).filter(|s| s.is_ascii()).map_or_else( || { let span = p.value.span(); source[span.start as usize..span.end as usize].to_string() }, - |s| crate::output::emitter::escape_string(s, false), + escape_string_for_linker, ); attrs.push(key); attrs.push(value); @@ -1067,7 +1067,7 @@ fn build_host_attrs( // Split classes and add each separately attrs.push("1".to_string()); // AttributeMarker.Classes for class in class_attr.split_whitespace() { - attrs.push(escape_string(class, false)); + attrs.push(escape_string_for_linker(class)); } } @@ -1078,8 +1078,8 @@ fn build_host_attrs( // Parse like the template pipeline's `parse_extracted_styles` phase, which // skips `;` and `:` inside quotes and parentheses (`url('a;b')`). for (prop, val) in parse_style_string(style_attr) { - attrs.push(crate::output::emitter::escape_string(&prop, false)); - attrs.push(crate::output::emitter::escape_string(&val, false)); + attrs.push(escape_string_for_linker(&prop)); + attrs.push(escape_string_for_linker(&val)); } } @@ -1441,7 +1441,7 @@ fn link_pipe( ns: &str, type_name: &str, ) -> Option { - let pipe_name = escape_string(get_string_property(meta, "name")?, false); + let pipe_name = escape_string_for_linker(get_string_property(meta, "name")?); let pure = get_property_source(meta, "pure", source).unwrap_or("true"); let standalone = get_bool_property(meta, "isStandalone") .unwrap_or_else(|| get_default_standalone_value(meta)); @@ -1562,10 +1562,10 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source // public and declared name when the object form leaves them out). let (quoted_key, key_literal) = match &p.key { PropertyKey::StaticIdentifier(ident) => { - (quote_key(&ident.name), crate::output::emitter::escape_string(&ident.name, false)) + (quote_key(&ident.name), escape_string_for_linker(&ident.name)) } PropertyKey::StringLiteral(s) => match s.value.as_str() { - Some(key) => (quote_key(key), crate::output::emitter::escape_string(key, false)), + Some(key) => (quote_key(key), escape_string_for_linker(key)), // A lone surrogate (`'\uD800'`) has no `&str`: use the key as written. None => { let key = source[s.span.start as usize..s.span.end as usize].to_string(); @@ -1606,14 +1606,36 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source } let arr_source = &source[arr.span.start as usize + 1..arr.span.end as usize - 1]; + // The names as new literals, like the Angular linker reads them + // (`getString()`), so an escape comes out as raw text; the + // transform function stays as written. + let names = || -> Option { + let mut parts = Vec::with_capacity(arr.elements.len()); + for (i, element) in arr.elements.iter().enumerate() { + let element = element.as_expression()?; + parts.push(if i < 2 { + static_string_literal(element, source)? + } else { + let span = element.span(); + source[span.start as usize..span.end as usize].to_string() + }); + } + Some(parts.join(", ")) + }; match arr.elements.len() { // Declaration format: ["publicName", "classPropertyName"] // Convert to: [0, "publicName", "classPropertyName"] - 2 => entries.push(format!("{quoted_key}: [0, {arr_source}]")), + 2 => entries.push(format!( + "{quoted_key}: [0, {}]", + names().as_deref().unwrap_or(arr_source) + )), // Since Angular 16.1, support multi-directive inputs, so we need to convert to the definition format. // Declaration format: ["publicName", "classPropertyName", transformFn] // Convert to: [2, "publicName", "classPropertyName", transformFn] - 3 => entries.push(format!("{quoted_key}: [2, {arr_source}]")), + 3 => entries.push(format!( + "{quoted_key}: [2, {}]", + names().as_deref().unwrap_or(arr_source) + )), // 4+ elements likely already in definition format, keep as is. _ => entries.push(format!("{quoted_key}: {val}")), } @@ -1675,6 +1697,56 @@ fn convert_inputs_to_definition_format(inputs_obj: &ObjectExpression<'_>, source format!("{{ {} }}", entries.join(", ")) } +/// A directive's `outputs` for the definition. The Angular linker reads each +/// public name as a string (`toLiteral(value => value.getString())`) and +/// prints it as a new literal, so an escape in the declaration (`"öut"`) +/// comes out as raw text (`"öut"`). An entry that isn't a key with a static +/// string keeps the whole object as written. +fn link_outputs(meta: &ObjectExpression<'_>, source: &str) -> Option { + let as_written = get_property_source(meta, "outputs", source)?; + let Some(outputs) = get_object_property(meta, "outputs") else { + return Some(as_written.to_string()); + }; + if outputs.properties.is_empty() { + return Some(as_written.to_string()); + } + let mut entries = Vec::with_capacity(outputs.properties.len()); + for prop in &outputs.properties { + let ObjectPropertyKind::ObjectProperty(p) = prop else { + return Some(as_written.to_string()); + }; + let key = match &p.key { + PropertyKey::StaticIdentifier(ident) => quote_key(&ident.name), + PropertyKey::StringLiteral(s) => match s.value.as_str() { + Some(key) => quote_key(key), + None => return Some(as_written.to_string()), + }, + _ => return Some(as_written.to_string()), + }; + let Some(value) = static_string_literal(&p.value, source) else { + return Some(as_written.to_string()); + }; + entries.push(format!("{key}: {value}")); + } + Some(format!("{{ {} }}", entries.join(", "))) +} + +/// A directive's `exportAs` for the definition: each name read as a string and +/// printed as a new literal, as the Angular linker does +/// (`getArray('exportAs').map(entry => entry.getString())`). +fn link_export_as(meta: &ObjectExpression<'_>, source: &str) -> Option { + let as_written = get_property_source(meta, "exportAs", source)?; + let Some(names) = get_array_property(meta, "exportAs") else { + return Some(as_written.to_string()); + }; + let names: Option> = names + .elements + .iter() + .map(|element| static_string_literal(element.as_expression()?, source)) + .collect(); + Some(names.map_or_else(|| as_written.to_string(), |names| format!("[{}]", names.join(", ")))) +} + /// Link ɵɵngDeclareDirective → ɵɵdefineDirective. fn link_directive<'a>( meta: &ObjectExpression<'_>, @@ -1705,10 +1777,10 @@ fn link_directive<'a>( let converted = convert_inputs_to_definition_format(inputs_obj, source); parts.push(format!("inputs: {converted}")); } - if let Some(outputs) = get_property_source(meta, "outputs", source) { + if let Some(outputs) = link_outputs(meta, source) { parts.push(format!("outputs: {outputs}")); } - if let Some(export_as) = get_property_source(meta, "exportAs", source) { + if let Some(export_as) = link_export_as(meta, source) { parts.push(format!("exportAs: {export_as}")); } let standalone = get_bool_property(meta, "isStandalone") @@ -1951,7 +2023,8 @@ fn build_queries( query_selectors(selectors).map_or_else( || source[expr.span().start as usize..expr.span().end as usize].to_string(), |selectors| { - JsEmitter::new().emit_expression(&pool_string_array(pool, &selectors)) + JsEmitter::for_linker() + .emit_expression(&pool_string_array(pool, &selectors)) }, ) } else { @@ -2197,7 +2270,7 @@ fn build_features( // emits `ɵɵControlFeature("formField")` or `ɵɵControlFeature(null)` if let Some(control_create) = get_object_property(meta, "controlCreate") { let pass_through = match get_string_property(control_create, "passThroughInput") { - Some(s) => escape_string(s, false), + Some(s) => escape_string_for_linker(s), None => "null".to_string(), }; features.push(format!("{ns}.\u{0275}\u{0275}ControlFeature({pass_through})")); @@ -2346,7 +2419,7 @@ fn link_component<'a>( .filter(|attrs| !attrs.is_empty()) .map(|attrs| { let attrs: Vec<&str> = attrs.iter().map(String::as_str).collect(); - JsEmitter::new().emit_expression(&pool_string_array(&mut attrs_pool, &attrs)) + JsEmitter::for_linker().emit_expression(&pool_string_array(&mut attrs_pool, &attrs)) }); let attrs_declarations_js = declare_pooled_constants_js(&mut attrs_pool, ns); pool.close(&mut attrs_pool); @@ -2417,12 +2490,12 @@ fn link_component<'a>( } // 9. outputs - if let Some(outputs) = get_property_source(meta, "outputs", source) { + if let Some(outputs) = link_outputs(meta, source) { parts.push(format!("outputs: {outputs}")); } // 10. exportAs - if let Some(export_as) = get_property_source(meta, "exportAs", source) { + if let Some(export_as) = link_export_as(meta, source) { parts.push(format!("exportAs: {export_as}")); } @@ -2675,9 +2748,18 @@ fn link_component<'a>( // 22. changeDetection — resolve the enum member to its numeric value. // Angular v22: OnPush = 0, Eager = 1 (Default is a deprecated alias for // Eager). The runtime computes `onPush = changeDetection !== Eager`. + // + // A linker before v22 (when the app's version is known) defaults to + // `Default` and leaves that out, like its compiler, which only writes a + // strategy other than `Default` (`compileComponentFromMetadata`). So there + // only an explicit OnPush gives a field. + let pre_v22_linker = + options.angular_version.is_some_and(|v| !v.change_detection_default_is_on_push()); if let Some(cd) = get_property_source(meta, "changeDetection", source) { if cd.contains("Eager") || cd.contains("Default") { - parts.push("changeDetection: 1".to_string()); + if !pre_v22_linker { + parts.push("changeDetection: 1".to_string()); + } } else if cd.contains("OnPush") { // Emitted explicitly rather than left to the runtime default. The TS // compiler omits OnPush here, but it ships with the runtime it targets; @@ -2688,7 +2770,7 @@ fn link_component<'a>( // silently lose OnPush. `0` is correct on both. parts.push("changeDetection: 0".to_string()); } - } else if !has_on_push_by_default(meta) { + } else if !pre_v22_linker && !has_on_push_by_default(meta) { // Omitted. Which strategy that meant depends on the version the library // was compiled against, and the runtime reads an absent field as OnPush, // so a pre-v22 declaration has to say Eager out loud. Without this a diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index 0d04911a3..8b383c76d 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -80,6 +80,10 @@ pub struct NgModuleMetadata<'a> { /// Module ID for registration. pub id: Option>, + /// The `id` literal as written, when ngtsc keeps it (see + /// `string_literal_as_written`). + pub id_source: Option>, + /// Whether the lists are wrapped in `() => [...]`. The parser sets it /// for a `forwardRef`; the transform replaces it with ngtsc's rule (see /// `module_with_providers`). @@ -112,6 +116,7 @@ impl<'a> NgModuleMetadata<'a> { bootstrap: Vec::new_in(&allocator), schemas: Vec::new_in(&allocator), id: None, + id_source: None, contains_forward_decls: false, deps: None, } @@ -190,13 +195,16 @@ impl<'a> NgModuleMetadata<'a> { // Set module ID if present if let Some(id) = &self.id { - let id_expr = OutputExpression::Literal(Box::new_in( - crate::output::ast::LiteralExpr { - value: crate::output::ast::LiteralValue::String(id.clone()), - source_span: None, - }, - &allocator, - )); + let id_expr = match self.id_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => OutputExpression::Literal(Box::new_in( + crate::output::ast::LiteralExpr { + value: crate::output::ast::LiteralValue::String(id.clone()), + source_span: None, + }, + &allocator, + )), + }; builder = builder.id(id_expr); } @@ -337,6 +345,8 @@ pub(crate) fn extract_ng_module_metadata_in<'a>( } "id" => { metadata.id = extract_string_value(&prop.value); + metadata.id_source = + crate::output::oxc_converter::literal_as_written(allocator, &prop.value); } _ => { // Unknown property - ignore (e.g., "jit") @@ -549,6 +559,8 @@ fn extract_param_dependency<'a>( let mut host = false; let mut inject_token: Option> = None; let mut attribute_name: Option> = None; + // The attribute name as written, when ngtsc keeps it (see `string_literal_as_written`). + let mut attribute_source: Option> = None; for decorator in ¶m.decorators { if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { @@ -573,6 +585,8 @@ fn extract_param_dependency<'a>( && let Some(name) = s.value.as_str() { attribute_name = Some(name.into()); + attribute_source = + crate::output::oxc_converter::string_literal_as_written(s); } } } @@ -598,7 +612,10 @@ fn extract_param_dependency<'a>( // ngtsc's `attributeNameType` is the attribute name itself (di.ts), so it is // set whatever the parameter's type and compiles to `ɵɵinjectAttribute`. return R3DependencyMetadata { - token: Some(name.clone_in(allocator)), + token: Some(match attribute_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => name.clone_in(allocator), + }), attribute_name_type: Some(name), host, optional, diff --git a/crates/oxc_angular_compiler/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index bcf974335..97ca6410b 100644 --- a/crates/oxc_angular_compiler/src/output/ast.rs +++ b/crates/oxc_angular_compiler/src/output/ast.rs @@ -509,15 +509,24 @@ pub struct LiteralMapEntry<'a> { pub value: OutputExpression<'a>, pub quoted: bool, pub is_spread: bool, + /// The key is source text, quotes included, printed as is. ngtsc copies an + /// object from the source as written, so a quoted key with text outside ASCII + /// (`'é-x'`) keeps its source form instead of being escaped. + pub key_as_written: bool, } impl<'a> LiteralMapEntry<'a> { pub fn new(key: Ident<'a>, value: OutputExpression<'a>, quoted: bool) -> Self { - Self { key, value, quoted, is_spread: false } + Self { key, value, quoted, is_spread: false, key_as_written: false } } pub fn spread(value: OutputExpression<'a>) -> Self { - Self { key: Ident::from(""), value, quoted: false, is_spread: true } + Self { key: Ident::from(""), value, quoted: false, is_spread: true, key_as_written: false } + } + + /// An entry whose key is a quoted key as written in the source. + pub fn with_key_as_written(key: Ident<'a>, value: OutputExpression<'a>) -> Self { + Self { key, value, quoted: true, is_spread: false, key_as_written: true } } } @@ -1200,6 +1209,7 @@ impl<'a> OutputExpression<'a> { value: entry.value.clone_in(allocator), quoted: entry.quoted, is_spread: entry.is_spread, + key_as_written: entry.key_as_written, }); } OutputExpression::LiteralMap(Box::new_in( diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index 7a1471522..9941bdd79 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -377,18 +377,33 @@ pub struct JsEmitter { escape_dollar_in_strings: bool, /// Name to print instead of `i0` for the `@angular/core` namespace. core_namespace: Option, + /// How string literals print text outside ASCII. + non_ascii: NonAscii, } impl JsEmitter { - /// Create a new JavaScript emitter. + /// Create a new JavaScript emitter. Strings print like ngtsc prints them: text + /// outside ASCII as `\uXXXX` escapes (see [`escape_string`]). pub fn new() -> Self { - Self { escape_dollar_in_strings: false, core_namespace: None } + Self { escape_dollar_in_strings: false, core_namespace: None, non_ascii: NonAscii::Escape } } - /// Print `ns` instead of `i0` for the `@angular/core` namespace. The linker needs this - /// because a minified library may import `@angular/core` under any name. + /// An emitter for linker output: strings print like the Angular linker's Babel + /// generator prints them (see [`escape_string_for_linker`]). + pub fn for_linker() -> Self { + Self { escape_dollar_in_strings: false, core_namespace: None, non_ascii: NonAscii::Linker } + } + + /// A linker emitter (see [`JsEmitter::for_linker`]) that prints `ns` instead of `i0` + /// for the `@angular/core` namespace, because a minified library may import + /// `@angular/core` under any name. pub fn with_core_namespace(ns: &str) -> Self { - Self { escape_dollar_in_strings: false, core_namespace: Some(ns.to_string()) } + Self { core_namespace: Some(ns.to_string()), ..Self::for_linker() } + } + + /// `s` as a quoted string literal, in this emitter's style. + fn quote(&self, s: &str) -> String { + escape_string_with(s, self.escape_dollar_in_strings, self.non_ascii) } /// Emit an expression to a string. @@ -672,7 +687,7 @@ impl JsEmitter { // written as a key (ngtsc prints it raw, as U+FFFD). if has_lone_surrogate(&e.name) { ctx.print(if e.optional { "?.[" } else { "[" }); - let key = escape_string(&e.name, self.escape_dollar_in_strings); + let key = self.quote(&e.name); ctx.print_with_span(&key, prop_span); ctx.print("]"); } else { @@ -896,7 +911,7 @@ impl JsEmitter { } match &e.url { DynamicImportUrl::String(s) => { - ctx.print(&escape_string(s, self.escape_dollar_in_strings)); + ctx.print(&self.quote(s)); } DynamicImportUrl::Expression(expr) => { self.visit_expression(expr, ctx); @@ -956,7 +971,7 @@ impl JsEmitter { ctx.print_with_span(&format_number_like_js(*n), source_span); } LiteralValue::String(s) => { - ctx.print_with_span(&escape_string(s, self.escape_dollar_in_strings), source_span); + ctx.print_with_span(&self.quote(s), source_span); } } } @@ -1013,9 +1028,17 @@ impl JsEmitter { ctx.print("..."); self.visit_expression(&entry.value, ctx); } else { - let key = - escape_identifier(&entry.key, self.escape_dollar_in_strings, entry.quoted); - ctx.print(&key); + if entry.key_as_written { + ctx.print(&entry.key); + } else { + let key = escape_identifier( + &entry.key, + self.escape_dollar_in_strings, + entry.quoted, + self.non_ascii, + ); + ctx.print(&key); + } ctx.print(":"); self.visit_expression(&entry.value, ctx); } @@ -1344,14 +1367,39 @@ fn is_nullish_coalesce(expr: &OutputExpression<'_>) -> bool { } } -/// Escape a string for JavaScript output. +/// How a string literal prints text outside ASCII. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NonAscii { + /// As `\uXXXX` escapes, one per UTF-16 code unit, like the TypeScript printer + /// that writes ngtsc's output (`escapeNonAsciiString`). + Escape, + /// As raw UTF-8, except the Unicode spaces that the Angular linker's Babel 8 + /// generator escapes (jsesc with `minimal: true`). + Linker, +} + +/// Escape a string for JavaScript output, the way ngtsc prints a string literal +/// it creates. /// /// Uses double quotes to match Angular's output style. /// Escapes `"`, `\`, `\n`, `\r`, `$` (when requested), and ASCII control characters -/// as `\uNNNN` sequences. Non-ASCII characters (code point > 0x7E) are emitted as -/// raw UTF-8 to match Angular's TypeScript emitter behavior (see `escapeIdentifier` -/// in `abstract_emitter.ts`), which only escapes `'`, `\`, `\n`, `\r`, and `$`. +/// as `\uNNNN` sequences. Text outside ASCII is escaped as `\uXXXX`, one escape per +/// UTF-16 code unit in upper-case hex (`é` is `é`, `😀` is `😀`), +/// like the TypeScript printer (`escapeNonAsciiString`) that writes ngtsc's output. +/// A literal that ngtsc copies from the source keeps the source text instead +/// (a `RawSource` expression). pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String { + escape_string_with(input, escape_dollar, NonAscii::Escape) +} + +/// Escape a string for linker output, the way the Angular linker prints a string +/// literal it creates: its Babel 8 generator (jsesc with `minimal: true`) keeps text +/// outside ASCII as raw UTF-8, but escapes the Unicode spaces (`\xA0`, `\u2028`, ...). +pub(crate) fn escape_string_for_linker(input: &str) -> String { + escape_string_with(input, false, NonAscii::Linker) +} + +fn escape_string_with(input: &str, escape_dollar: bool, non_ascii: NonAscii) -> String { let mut result = String::with_capacity(input.len() + 2); result.push('"'); for piece in pieces(input) { @@ -1373,9 +1421,25 @@ pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String { c if (' '..='\x7E').contains(&c) => result.push(c), // DEL (0x7F) is an ASCII control character and must be escaped '\x7F' => push_unicode_escape(&mut result, 0x7F), - // Non-ASCII characters (> 0x7F) are emitted as raw UTF-8 to match - // Angular's TypeScript emitter, which does not escape them. - c if (c as u32) > 0x7F => result.push(c), + c if (c as u32) > 0x7F => match non_ascii { + NonAscii::Escape => { + for unit in c.encode_utf16(&mut [0u16; 2]) { + push_unicode_escape(&mut result, u32::from(*unit)); + } + } + // jsesc's `regexWhitespace`; `\xA0` gets the short form. + NonAscii::Linker => match c { + '\u{A0}' => result.push_str("\\xA0"), + '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' + | '\u{2029}' + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' => push_unicode_escape(&mut result, c as u32), + c => result.push(c), + }, + }, // ASCII control characters (0x00-0x1F) are escaped as \uNNNN. c => { push_unicode_escape(&mut result, c as u32); @@ -1386,6 +1450,26 @@ pub(crate) fn escape_string(input: &str, escape_dollar: bool) -> String { result } +/// `s` with each character outside ASCII written as `\uXXXX` escapes, one per +/// UTF-16 code unit, as the TypeScript printer writes a string ngtsc creates +/// (for a module specifier in an import line the compiler adds). +pub(crate) fn escape_non_ascii(s: &str) -> Cow<'_, str> { + if s.is_ascii() { + return Cow::Borrowed(s); + } + let mut result = String::with_capacity(s.len() + 8); + for c in s.chars() { + if c.is_ascii() { + result.push(c); + } else { + for unit in c.encode_utf16(&mut [0u16; 2]) { + push_unicode_escape(&mut result, u32::from(*unit)); + } + } + } + Cow::Owned(result) +} + /// The raw text of a template literal part, printed verbatim like the TypeScript printer /// prints a template's `rawText`. The raw text is already escaped for a template literal /// (backslashes, backticks and `${`); a stored lone surrogate, which UTF-8 output can't hold, @@ -1415,7 +1499,12 @@ fn push_unicode_escape(buf: &mut String, code: u32) { } /// Escape an identifier for use as a property key. -fn escape_identifier(input: &Ident<'_>, escape_dollar: bool, always_quote: bool) -> String { +fn escape_identifier( + input: &Ident<'_>, + escape_dollar: bool, + always_quote: bool, + non_ascii: NonAscii, +) -> String { // Check if the identifier is a valid JavaScript identifier fn is_legal_identifier(s: &str) -> bool { let mut chars = s.chars(); @@ -1430,7 +1519,7 @@ fn escape_identifier(input: &Ident<'_>, escape_dollar: bool, always_quote: bool) } if always_quote || !is_legal_identifier(input) { - escape_string(input, escape_dollar) + escape_string_with(input, escape_dollar, non_ascii) } else { input.to_string() } @@ -1702,36 +1791,55 @@ mod tests { } #[test] - fn test_escape_string_unicode_literals() { - // Non-ASCII characters should be emitted as raw UTF-8 to match - // Angular's TypeScript emitter behavior (escapeIdentifier in abstract_emitter.ts). - - // × (multiplication sign U+00D7) -> raw UTF-8 - assert_eq!(escape_string("\u{00D7}", false), "\"\u{00D7}\""); - - //   (non-breaking space U+00A0) -> raw UTF-8 - assert_eq!(escape_string("\u{00A0}", false), "\"\u{00A0}\""); - - // Mixed ASCII and non-ASCII - assert_eq!(escape_string("a\u{00D7}b", false), "\"a\u{00D7}b\""); + fn test_escape_string_non_ascii_like_typescript_printer() { + // ngtsc's output goes through the TypeScript printer, which writes text + // outside ASCII in a string literal it creates as `\uXXXX`, one escape per + // UTF-16 code unit, upper-case hex (`escapeNonAsciiString`). Expected + // strings are what `ts.factory.createStringLiteral(s)` prints. + assert_eq!(escape_string("\u{00E9}", false), "\"\\u00E9\""); // é + assert_eq!(escape_string("\u{00D7}", false), "\"\\u00D7\""); // × + assert_eq!(escape_string("\u{00A0}", false), "\"\\u00A0\""); //   + assert_eq!(escape_string("\u{2013}", false), "\"\\u2013\""); // – + assert_eq!(escape_string("a\u{00D7}b", false), "\"a\\u00D7b\""); + // Outside the BMP: a surrogate pair. + assert_eq!(escape_string("\u{1F600}", false), "\"\\uD83D\\uDE00\""); + assert_eq!(escape_string("\u{10000}", false), "\"\\uD800\\uDC00\""); + assert_eq!(escape_string("\u{00E9}\u{1F600}", false), "\"\\u00E9\\uD83D\\uDE00\""); + assert_eq!(escape_string("\u{2028}\u{FEFF}", false), "\"\\u2028\\uFEFF\""); - // Multiple non-ASCII characters - assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\u{00D7}\u{00A0}\""); - - // Characters outside BMP (emoji) -> raw UTF-8 - assert_eq!(escape_string("\u{1F600}", false), "\"\u{1F600}\""); - - // Common HTML entities -> all emitted as raw UTF-8 - assert_eq!(escape_string("\u{00A9}", false), "\"\u{00A9}\""); // © © - assert_eq!(escape_string("\u{00AE}", false), "\"\u{00AE}\""); // ® ® - assert_eq!(escape_string("\u{2014}", false), "\"\u{2014}\""); // — — - assert_eq!(escape_string("\u{2013}", false), "\"\u{2013}\""); // – – + // ASCII printable chars (0x20-0x7E) should remain literal + assert_eq!(escape_string(" ~", false), "\" ~\""); + assert_eq!(escape_string("abc123!@#", false), "\"abc123!@#\""); + } - // Greek letter alpha - assert_eq!(escape_string("\u{03B1}", false), "\"\u{03B1}\""); // α + #[test] + fn test_escape_string_for_linker_like_babel_generator() { + // The Angular linker's Babel 8 generator (jsesc, `minimal: true`) keeps text + // outside ASCII as raw UTF-8 and escapes only the Unicode spaces. Expected + // strings are what `@babel/generator` 8.0.1 prints for `t.stringLiteral(s)`. + assert_eq!(escape_string_for_linker("\u{00E9}\u{1F600}"), "\"\u{00E9}\u{1F600}\""); + assert_eq!( + escape_string_for_linker("\u{00D7}\u{FEFF}\u{0085}"), + "\"\u{00D7}\u{FEFF}\u{0085}\"" + ); + assert_eq!(escape_string_for_linker("a\u{00A0}b"), "\"a\\xA0b\""); + assert_eq!(escape_string_for_linker("a\u{2028}b\u{2029}c"), "\"a\\u2028b\\u2029c\""); + assert_eq!( + escape_string_for_linker("\u{1680}\u{2000}\u{200A}\u{202F}\u{205F}\u{3000}"), + "\"\\u1680\\u2000\\u200A\\u202F\\u205F\\u3000\"" + ); + } - // Accented Latin letter - assert_eq!(escape_string("\u{00E9}", false), "\"\u{00E9}\""); // é + #[test] + fn test_emitter_string_style() { + let alloc = Allocator::default(); + let value = OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String("\u{00FC}".into()), source_span: None }, + &&alloc, + )); + assert_eq!(JsEmitter::new().emit_expression(&value), "\"\\u00FC\""); + assert_eq!(JsEmitter::for_linker().emit_expression(&value), "\"\u{00FC}\""); + assert_eq!(JsEmitter::with_core_namespace("ng").emit_expression(&value), "\"\u{00FC}\""); } #[test] @@ -1749,40 +1857,6 @@ mod tests { assert_eq!(escape_string("\r", false), "\"\\r\""); } - #[test] - fn test_escape_string_non_ascii_as_raw_utf8() { - // Non-ASCII characters should be emitted as raw UTF-8 to match - // Angular's TypeScript emitter behavior (escapeIdentifier in abstract_emitter.ts). - - // Non-breaking space U+00A0 - assert_eq!(escape_string("\u{00A0}", false), "\"\u{00A0}\""); - - // En dash U+2013 - assert_eq!(escape_string("\u{2013}", false), "\"\u{2013}\""); - - // Trademark U+2122 - assert_eq!(escape_string("\u{2122}", false), "\"\u{2122}\""); - - // Infinity U+221E - assert_eq!(escape_string("\u{221E}", false), "\"\u{221E}\""); - - // Mixed ASCII and non-ASCII - assert_eq!(escape_string("a\u{00D7}b", false), "\"a\u{00D7}b\""); - - // Multiple non-ASCII characters - assert_eq!(escape_string("\u{00D7}\u{00A0}", false), "\"\u{00D7}\u{00A0}\""); - - // Characters above BMP (emoji) -> raw UTF-8 - assert_eq!(escape_string("\u{1F600}", false), "\"\u{1F600}\""); - - // U+10000 (first supplementary char) -> raw UTF-8 - assert_eq!(escape_string("\u{10000}", false), "\"\u{10000}\""); - - // ASCII printable chars (0x20-0x7E) should remain literal - assert_eq!(escape_string(" ~", false), "\" ~\""); - assert_eq!(escape_string("abc123!@#", false), "\"abc123!@#\""); - } - // ======================================================================== // Source Map Tests // ======================================================================== diff --git a/crates/oxc_angular_compiler/src/output/oxc_converter.rs b/crates/oxc_angular_compiler/src/output/oxc_converter.rs index 82fe737ba..e61e245d6 100644 --- a/crates/oxc_angular_compiler/src/output/oxc_converter.rs +++ b/crates/oxc_angular_compiler/src/output/oxc_converter.rs @@ -33,6 +33,71 @@ use crate::directive::quote_string_literal; // Main Conversion Function // ============================================================================ +/// A string literal from the source as an output expression. +/// +/// ngtsc copies such a literal as written (a `WrappedNodeExpr`), and the +/// TypeScript printer keeps its source text, while it escapes text outside +/// ASCII in a literal the compiler creates. So a literal with text outside ASCII +/// keeps its source text here; any other becomes a plain string literal. +pub fn string_literal_expr<'a>( + allocator: &'a Allocator, + lit: &oxc_ast::ast::StringLiteral<'a>, +) -> OutputExpression<'a> { + if let Some(source) = string_literal_as_written(lit) { + return raw_source(allocator, source); + } + match lit.value.as_str() { + Some(value) => OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(value.into()), source_span: None }, + &allocator, + )), + // A lone surrogate (`'\uD800'`) has no `&str`; with no source text, + // print its exact UTF-16 escapes. + None => OutputExpression::RawSource(Box::new_in( + RawSourceExpr { + source: Ident::from(allocator.alloc_str("e_string_literal(lit))), + source_span: None, + }, + &allocator, + )), + } +} + +/// Source text printed as is. +pub fn raw_source<'a>(allocator: &'a Allocator, source: Ident<'a>) -> OutputExpression<'a> { + OutputExpression::RawSource(Box::new_in( + RawSourceExpr { source, source_span: None }, + &allocator, + )) +} + +/// The source text, quotes included, of a string literal that holds text +/// outside ASCII (or a lone surrogate, which has no `&str`), for output that +/// keeps it as written like ngtsc does (see [`string_literal_expr`]). +pub fn string_literal_as_written<'a>(lit: &oxc_ast::ast::StringLiteral<'a>) -> Option> { + let raw = lit.raw?; + lit.value.as_str().is_none_or(|value| !value.is_ascii()).then(|| raw.into()) +} + +/// [`string_literal_as_written`] for a string literal or a template literal +/// with no expressions (`` `é` ``). +pub fn literal_as_written<'a>( + allocator: &'a Allocator, + expr: &Expression<'a>, +) -> Option> { + match expr { + Expression::StringLiteral(lit) => string_literal_as_written(lit), + Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { + let quasi = tpl.quasis.first()?; + let cooked = quasi.value.cooked?; + cooked.as_str().is_none_or(|value| !value.is_ascii()).then(|| { + Ident::from(allocator.alloc_str(&format!("`{}`", quasi.value.raw.as_str()))) + }) + } + _ => None, + } +} + /// Convert an OXC Expression to an OutputExpression. /// /// This handles common expression types used in Angular decorator properties. @@ -69,24 +134,7 @@ pub fn convert_oxc_expression<'a>( &allocator, ))), - Expression::StringLiteral(lit) => match lit.value.as_str() { - Some(value) => Some(OutputExpression::Literal(Box::new_in( - LiteralExpr { value: LiteralValue::String(value.into()), source_span: None }, - &allocator, - ))), - // A lone surrogate (`'\uD800'`) has no `&str`: keep the literal as - // written, or (no source text) print its exact UTF-16 escapes. - None => { - let source = match lit.raw { - Some(raw) => raw.into(), - None => Ident::from(allocator.alloc_str("e_string_literal(lit))), - }; - Some(OutputExpression::RawSource(Box::new_in( - RawSourceExpr { source, source_span: None }, - &allocator, - ))) - } - }, + Expression::StringLiteral(lit) => Some(string_literal_expr(allocator, lit)), // Identifiers Expression::Identifier(id) => Some(OutputExpression::ReadVar(Box::new_in( @@ -359,6 +407,16 @@ pub fn convert_plain_properties<'a>( if !is_plain_property(p) { continue; } + // A quoted key with text outside ASCII keeps its source text, + // as ngtsc copies the object as written. + if let PropertyKey::StringLiteral(lit) = &p.key + && let Some(key) = string_literal_as_written(lit) + && lit.value.as_str().is_some() + { + let value = convert_oxc_expression(allocator, &p.value, source_text)?; + entries.push(LiteralMapEntry::with_key_as_written(key, value)); + continue; + } let (key, quoted) = match &p.key { PropertyKey::StaticIdentifier(id) => (id.name.clone().into(), false), // A key with a lone surrogate (`'\uD800'`) has no `&str` diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index 671b731a0..5b4177fde 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -61,6 +61,11 @@ pub struct PartialComponentInputs<'a, 'm> { /// content). Partial mode emits this as a string literal — the linker /// re-parses it. pub template: &'a str, + /// The inline template literal as written in the source (quotes or + /// backticks included), when it holds text outside ASCII. ngtsc emits an + /// inline string or template literal as the source node, so its text is + /// kept, not escaped. + pub template_source: Option<&'a str>, /// Whether the template came from an inline `template: '...'` literal /// (`true`) versus an external `templateUrl` (`false`). pub is_inline: bool, @@ -233,7 +238,10 @@ pub fn compile_declare_component_from_metadata<'a>( entries.push(LiteralMapEntry::new( Ident::from("template"), - string_literal_str(allocator, inputs.template), + match inputs.template_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source.into()), + None => string_literal_str(allocator, inputs.template), + }, false, )); diff --git a/crates/oxc_angular_compiler/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 9e7c3077e..c516bf864 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -326,6 +326,8 @@ fn extract_param_dependency<'a>( let mut host = false; let mut inject_token: Option> = None; let mut attribute_name: Option> = None; + // The attribute name as written, when ngtsc keeps it (see `string_literal_as_written`). + let mut attribute_source: Option> = None; for decorator in ¶m.decorators { if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { @@ -350,6 +352,8 @@ fn extract_param_dependency<'a>( && let Some(name) = s.value.as_str() { attribute_name = Some(name.into()); + attribute_source = + crate::output::oxc_converter::string_literal_as_written(s); } } } @@ -374,7 +378,10 @@ fn extract_param_dependency<'a>( // ngtsc's `attributeNameType` is the attribute name itself (di.ts), so it is // set whatever the parameter's type and compiles to `ɵɵinjectAttribute`. return R3DependencyMetadata { - token: Some(name.clone_in(allocator)), + token: Some(match attribute_source { + Some(source) => crate::output::oxc_converter::raw_source(allocator, source), + None => name.clone_in(allocator), + }), attribute_name_type: Some(name), host, optional, diff --git a/crates/oxc_angular_compiler/src/pipeline/emit.rs b/crates/oxc_angular_compiler/src/pipeline/emit.rs index c2f4ecb5b..d907f9669 100644 --- a/crates/oxc_angular_compiler/src/pipeline/emit.rs +++ b/crates/oxc_angular_compiler/src/pipeline/emit.rs @@ -1149,7 +1149,13 @@ fn convert_pure_function_body<'a>( let value = convert_pure_function_body(allocator, &map.values[i], params); let quoted = map.quoted.get(i).copied().unwrap_or(false); let is_spread = map.spreads.get(i).copied().unwrap_or(false); - entries.push(LiteralMapEntry { key, value, quoted, is_spread }); + entries.push(LiteralMapEntry { + key, + value, + quoted, + is_spread, + key_as_written: false, + }); } OutputExpression::LiteralMap(Box::new_in( LiteralMapExpr { entries, source_span: None }, @@ -1188,7 +1194,13 @@ fn convert_pure_function_body<'a>( let value = convert_pure_function_body(allocator, &map.values[i], params); let quoted = map.quoted.get(i).copied().unwrap_or(false); let is_spread = map.spreads.get(i).copied().unwrap_or(false); - entries.push(LiteralMapEntry { key, value, quoted, is_spread }); + entries.push(LiteralMapEntry { + key, + value, + quoted, + is_spread, + key_as_written: false, + }); } OutputExpression::LiteralMap(Box::new_in( LiteralMapExpr { entries, source_span: None }, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs index 3767b3897..d91b00ae1 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs @@ -492,6 +492,7 @@ fn clone_expression<'a>( value: clone_expression(allocator, &entry.value, diagnostics), quoted: entry.quoted, is_spread: entry.is_spread, + key_as_written: entry.key_as_written, }); } OutputExpression::LiteralMap(Box::new_in( 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 4770af9e0..760b7985b 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 @@ -1004,7 +1004,13 @@ pub fn convert_ir_expression<'a>( let is_spread = map.spreads.get(i).copied().unwrap_or(false); let converted_value = convert_ir_expression(allocator, core_namespace, value, expressions, root_xref); - entries.push(LiteralMapEntry { key, value: converted_value, quoted, is_spread }); + entries.push(LiteralMapEntry { + key, + value: converted_value, + quoted, + is_spread, + key_as_written: false, + }); } OutputExpression::LiteralMap(Box::new_in( LiteralMapExpr { entries, source_span: map.source_span }, @@ -1043,7 +1049,13 @@ pub fn convert_ir_expression<'a>( let is_spread = map.spreads.get(i).copied().unwrap_or(false); let converted_value = convert_ir_expression(allocator, core_namespace, value, expressions, root_xref); - entries.push(LiteralMapEntry { key, value: converted_value, quoted, is_spread }); + entries.push(LiteralMapEntry { + key, + value: converted_value, + quoted, + is_spread, + key_as_written: false, + }); } OutputExpression::LiteralMap(Box::new_in( LiteralMapExpr { entries, source_span: map.source_span }, diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 2ea704696..7e351ef44 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -171,13 +171,12 @@ fn test_multiple_interpolations() { #[test] fn test_html_entity_between_interpolations() { - // HTML entity × between two interpolations should produce raw × in the output + // HTML entity × between two interpolations gives × (U+00D7), which + // ngtsc's printer writes as `\u00D7`. let js = compile_template_to_js("
{{ a }}×{{ b }}
", "TestComponent"); - // Should produce: textInterpolate2("", ctx.a, "×", ctx.b) - // Note: × (multiplication sign) = U+00D7, emitted as raw UTF-8 assert!( - js.contains("textInterpolate2(\"\",ctx.a,\"\u{00D7}\",ctx.b)"), - "Expected textInterpolate2 with raw times character. Got:\n{js}" + js.contains("textInterpolate2(\"\",ctx.a,\"\\u00D7\",ctx.b)"), + "Expected textInterpolate2 with an escaped times character. Got:\n{js}" ); } @@ -185,12 +184,10 @@ fn test_html_entity_between_interpolations() { fn test_html_entity_at_start_of_interpolation() { // Entity at start: ×{{ a }} let js = compile_template_to_js("
×{{ a }}
", "TestComponent"); - // Should produce: textInterpolate1("×", ctx.a) - // Note: × (multiplication sign) = U+00D7, emitted as raw UTF-8 assert!( - js.contains("textInterpolate1(\"\u{00D7}\",ctx.a)") - || js.contains("textInterpolate(\"\u{00D7}\",ctx.a)"), - "Expected textInterpolate with raw times character at start. Got:\n{js}" + js.contains("textInterpolate1(\"\\u00D7\",ctx.a)") + || js.contains("textInterpolate(\"\\u00D7\",ctx.a)"), + "Expected textInterpolate with an escaped times character at start. Got:\n{js}" ); } @@ -199,11 +196,10 @@ fn test_multiple_html_entities_between_interpolations() { // Multiple entities: {{ a }} × {{ b }} let js = compile_template_to_js("
{{ a }} × {{ b }}
", "TestComponent"); - // Should produce: textInterpolate2("", ctx.a, "\u{00A0}×\u{00A0}", ctx.b) - // Note:   = U+00A0, × = U+00D7, both emitted as raw UTF-8 + //   = U+00A0, × = U+00D7, both escaped like ngtsc prints them. assert!( - js.contains("textInterpolate2(\"\",ctx.a,\"\u{00A0}\u{00D7}\u{00A0}\",ctx.b)"), - "Expected textInterpolate2 with raw Unicode entities. Got:\n{js}" + js.contains("textInterpolate2(\"\",ctx.a,\"\\u00A0\\u00D7\\u00A0\",ctx.b)"), + "Expected textInterpolate2 with escaped Unicode entities. Got:\n{js}" ); } @@ -6551,16 +6547,16 @@ fn test_i18n_attribute_message_uses_attribute_value() { } /// An interpolated `i18n-` keeps its text and gets an INTERPOLATION placeholder. -/// Angular 22.1.5: goog.getMsg("Hi {$interpolation}!", { "interpolation": "�0�" }, ...) -/// $localize `:@@hi:Hi ${"�0�"}:INTERPOLATION:!` +/// Angular 22.1.5: goog.getMsg("Hi {$interpolation}!", { "interpolation": "\uFFFD0\uFFFD" }, ...) +/// $localize `:@@hi:Hi ${"\uFFFD0\uFFFD"}:INTERPOLATION:!` #[test] fn test_i18n_interpolated_attribute_message_uses_attribute_value() { let js = compile_i18n_component(r#"
"#); assert_contains( &js, - "goog.getMsg(\"Hi {$interpolation}!\",{\"interpolation\":\"\u{FFFD}0\u{FFFD}\"}", + "goog.getMsg(\"Hi {$interpolation}!\",{\"interpolation\":\"\\uFFFD0\\uFFFD\"}", ); - assert_contains(&js, "$localize `:@@hi:Hi ${\"\u{FFFD}0\u{FFFD}\"}:INTERPOLATION:!`"); + assert_contains(&js, "$localize `:@@hi:Hi ${\"\\uFFFD0\\uFFFD\"}:INTERPOLATION:!`"); } /// `i18n-` alongside an `i18n` element body: both messages keep their text. @@ -6600,7 +6596,7 @@ fn test_i18n_sole_icu_is_the_message() { // Post-processing runs after the Closure/$localize branch, for both modes. assert_contains( &js, - "}(i18n_1 = i0.ɵɵi18nPostprocess(i18n_1,{\"INTERPOLATION\":\"\u{FFFD}1\u{FFFD}\",\"VAR_PLURAL\":\"\u{FFFD}0\u{FFFD}\"}));", + "}(i18n_1 = i0.ɵɵi18nPostprocess(i18n_1,{\"INTERPOLATION\":\"\\uFFFD1\\uFFFD\",\"VAR_PLURAL\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains(&js, r#"return [i18n_1,["title",i18n_0]]"#); assert_contains(&js, "i0.ɵɵi18nExp(ctx.count)(ctx.count);"); @@ -6608,7 +6604,7 @@ fn test_i18n_sole_icu_is_the_message() { /// A sole `select` ICU. Angular 22.1.5: /// goog.getMsg("{VAR_SELECT, select, male {he} female {she} other {they}}") -/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_SELECT": "�0�" }); +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_SELECT": "\uFFFD0\uFFFD" }); #[test] fn test_i18n_sole_select_icu_is_the_message() { let js = compile_i18n_component( @@ -6622,13 +6618,13 @@ fn test_i18n_sole_select_icu_is_the_message() { assert_contains(&js, "$localize `{VAR_SELECT, select, male {he} female {she} other {they}}`"); assert_contains( &js, - "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}));", + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains(&js, "return [i18n_0]"); } /// A sole ICU with no interpolation still needs its VAR_PLURAL mapped. Angular 22.1.5: -/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_PLURAL": "�0�" }); +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "VAR_PLURAL": "\uFFFD0\uFFFD" }); #[test] fn test_i18n_sole_icu_without_interpolation() { let js = compile_i18n_component( @@ -6641,7 +6637,7 @@ fn test_i18n_sole_icu_without_interpolation() { ); assert_contains( &js, - "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_PLURAL\":\"\u{FFFD}0\u{FFFD}\"}));", + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_PLURAL\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains(&js, "return [i18n_0]"); } @@ -6649,8 +6645,8 @@ fn test_i18n_sole_icu_without_interpolation() { /// Elements inside a sole ICU become tag placeholders that `ɵɵi18nPostprocess` maps back to /// markup. Angular 22.1.5: /// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "CLOSE_BOLD_TEXT": "", "CLOSE_ITALIC_TEXT": "", -/// "INTERPOLATION": "�1�", "START_BOLD_TEXT": "", "START_ITALIC_TEXT": "", -/// "VAR_PLURAL": "�0�" }); +/// "INTERPOLATION": "\uFFFD1\uFFFD", "START_BOLD_TEXT": "", "START_ITALIC_TEXT": "", +/// "VAR_PLURAL": "\uFFFD0\uFFFD" }); #[test] fn test_i18n_sole_icu_with_elements() { let js = compile_i18n_component( @@ -6662,7 +6658,7 @@ fn test_i18n_sole_icu_with_elements() { ); assert_contains( &js, - "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"CLOSE_BOLD_TEXT\":\"\",\"CLOSE_ITALIC_TEXT\":\"\",\"INTERPOLATION\":\"\u{FFFD}1\u{FFFD}\",\"START_BOLD_TEXT\":\"\",\"START_ITALIC_TEXT\":\"\",\"VAR_PLURAL\":\"\u{FFFD}0\u{FFFD}\"}));", + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"CLOSE_BOLD_TEXT\":\"\",\"CLOSE_ITALIC_TEXT\":\"\",\"INTERPOLATION\":\"\\uFFFD1\\uFFFD\",\"START_BOLD_TEXT\":\"\",\"START_ITALIC_TEXT\":\"\",\"VAR_PLURAL\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains(&js, "return [i18n_0]"); } @@ -6670,9 +6666,9 @@ fn test_i18n_sole_icu_with_elements() { /// An ICU next to text and an interpolation is a real sub-message, referenced from the root /// message through the `icu` placeholder by variable. Angular 22.1.5: /// i18n_0 = "{VAR_PLURAL, plural, =1 {one item} other {{INTERPOLATION} items}}" -/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "INTERPOLATION": "�2�", "VAR_PLURAL": "�1�" }); -/// goog.getMsg("Hello {$interpolation}! {$icu}", { "icu": i18n_0, "interpolation": "�0�" }, ...) -/// $localize `Hello ${"�0�"}:INTERPOLATION:! ${i18n_0}:ICU:` +/// i18n_0 = i0.ɵɵi18nPostprocess(i18n_0, { "INTERPOLATION": "\uFFFD2\uFFFD", "VAR_PLURAL": "\uFFFD1\uFFFD" }); +/// goog.getMsg("Hello {$interpolation}! {$icu}", { "icu": i18n_0, "interpolation": "\uFFFD0\uFFFD" }, ...) +/// $localize `Hello ${"\uFFFD0\uFFFD"}:INTERPOLATION:! ${i18n_0}:ICU:` /// /// The `$localize` ICU placeholders carry the sub-message id (`:ICU@@:`), as Angular emits /// when legacy message ids are disabled; oxc emits no legacy ids. @@ -6691,15 +6687,15 @@ fn test_i18n_icu_with_sibling_content_is_sub_message() { ); assert_contains( &js, - "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"INTERPOLATION\":\"\u{FFFD}2\u{FFFD}\",\"VAR_PLURAL\":\"\u{FFFD}1\u{FFFD}\"}));", + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"INTERPOLATION\":\"\\uFFFD2\\uFFFD\",\"VAR_PLURAL\":\"\\uFFFD1\\uFFFD\"}));", ); assert_contains( &js, - "goog.getMsg(\"Hello {$interpolation}! {$icu}\",{\"icu\":i18n_0,\"interpolation\":\"\u{FFFD}0\u{FFFD}\"}", + "goog.getMsg(\"Hello {$interpolation}! {$icu}\",{\"icu\":i18n_0,\"interpolation\":\"\\uFFFD0\\uFFFD\"}", ); assert_contains( &js, - "$localize `Hello ${\"\u{FFFD}0\u{FFFD}\"}:INTERPOLATION:! ${i18n_0}:ICU@@3633748910064245637:`", + "$localize `Hello ${\"\\uFFFD0\\uFFFD\"}:INTERPOLATION:! ${i18n_0}:ICU@@3633748910064245637:`", ); assert_contains(&js, "return [i18n_1]"); assert_contains(&js, "i0.ɵɵi18nExp(ctx.name)(ctx.count)(ctx.count);"); @@ -6719,11 +6715,11 @@ fn test_i18n_two_icus_in_one_message() { ); assert_contains( &js, - "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}));", + "}(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains( &js, - "}(i18n_1 = i0.ɵɵi18nPostprocess(i18n_1,{\"VAR_PLURAL\":\"\u{FFFD}1\u{FFFD}\"}));", + "}(i18n_1 = i0.ɵɵi18nPostprocess(i18n_1,{\"VAR_PLURAL\":\"\\uFFFD1\\uFFFD\"}));", ); assert_contains(&js, r#"goog.getMsg("{$icu} and {$icu_1}",{"icu":i18n_0,"icu_1":i18n_1}"#); assert_contains( @@ -6736,7 +6732,7 @@ fn test_i18n_two_icus_in_one_message() { /// `$localize` placeholder names keep Angular's internal form (`START_BOLD_TEXT`), since they /// are part of the message id that `ng extract-i18n` computes. Angular 22.1.5: -/// $localize `Hello ${"�#2�"}:START_BOLD_TEXT:world${"�/#2�"}:CLOSE_BOLD_TEXT:!` +/// $localize `Hello ${"\uFFFD#2\uFFFD"}:START_BOLD_TEXT:world${"\uFFFD/#2\uFFFD"}:CLOSE_BOLD_TEXT:!` #[test] fn test_i18n_localize_placeholder_names_keep_underscores() { let js = compile_i18n_component(r#"Hello world!"#); @@ -6746,7 +6742,7 @@ fn test_i18n_localize_placeholder_names_keep_underscores() { ); assert_contains( &js, - "$localize `Hello ${\"\u{FFFD}#2\u{FFFD}\"}:START_BOLD_TEXT:world${\"\u{FFFD}/#2\u{FFFD}\"}:CLOSE_BOLD_TEXT:!`", + "$localize `Hello ${\"\\uFFFD#2\\uFFFD\"}:START_BOLD_TEXT:world${\"\\uFFFD/#2\\uFFFD\"}:CLOSE_BOLD_TEXT:!`", ); } @@ -6760,13 +6756,13 @@ fn test_i18n_attribute_with_empty_value_has_no_message() { } /// A custom placeholder name from `// i18n(ph="PH")` names the interpolation. Angular 22.1.5: -/// goog.getMsg("Hello {$ph}", { "ph": "�0�" }, ...) -/// $localize `Hello ${"�0�"}:PH:` +/// goog.getMsg("Hello {$ph}", { "ph": "\uFFFD0\uFFFD" }, ...) +/// $localize `Hello ${"\uFFFD0\uFFFD"}:PH:` #[test] fn test_i18n_custom_interpolation_placeholder_name() { let js = compile_i18n_component(r#"Hello {{ name // i18n(ph="PH") }}"#); - assert_contains(&js, "goog.getMsg(\"Hello {$ph}\",{\"ph\":\"\u{FFFD}0\u{FFFD}\"})"); - assert_contains(&js, "$localize `Hello ${\"\u{FFFD}0\u{FFFD}\"}:PH:`"); + assert_contains(&js, "goog.getMsg(\"Hello {$ph}\",{\"ph\":\"\\uFFFD0\\uFFFD\"})"); + assert_contains(&js, "$localize `Hello ${\"\\uFFFD0\\uFFFD\"}:PH:`"); } /// Whitespace next to an ICU is part of the message. Angular 22.1.5: @@ -6782,19 +6778,19 @@ fn test_i18n_keeps_whitespace_next_to_icu() { assert_contains(&js, r#"goog.getMsg("{$icu} {$startBoldText}x{$closeBoldText}","#); assert_contains( &js, - "$localize `${i18n_0}:ICU@@8078998681144872872: ${\"\u{FFFD}#2\u{FFFD}\"}:START_BOLD_TEXT:x${\"\u{FFFD}/#2\u{FFFD}\"}:CLOSE_BOLD_TEXT:`", + "$localize `${i18n_0}:ICU@@8078998681144872872: ${\"\\uFFFD#2\\uFFFD\"}:START_BOLD_TEXT:x${\"\\uFFFD/#2\\uFFFD\"}:CLOSE_BOLD_TEXT:`", ); } /// An element with a structural directive inside an i18n block gets one combined value per /// tag placeholder, and needs no post-processing. Angular 22.1.5: -/// { "closeTagSpan": "�/#1:1��/*2:1�", "startTagSpan": "�*2:1��#1:1�" } +/// { "closeTagSpan": "\uFFFD/#1:1\uFFFD\uFFFD/*2:1\uFFFD", "startTagSpan": "\uFFFD*2:1\uFFFD\uFFFD#1:1\uFFFD" } #[test] fn test_i18n_structural_directive_element_placeholders() { let js = compile_i18n_component(r#"
x
"#); assert_contains( &js, - "goog.getMsg(\"{$startTagSpan}x{$closeTagSpan}\",{\"closeTagSpan\":\"\u{FFFD}/#1:1\u{FFFD}\u{FFFD}/*2:1\u{FFFD}\",\"startTagSpan\":\"\u{FFFD}*2:1\u{FFFD}\u{FFFD}#1:1\u{FFFD}\"})", + "goog.getMsg(\"{$startTagSpan}x{$closeTagSpan}\",{\"closeTagSpan\":\"\\uFFFD/#1:1\\uFFFD\\uFFFD/*2:1\\uFFFD\",\"startTagSpan\":\"\\uFFFD*2:1\\uFFFD\\uFFFD#1:1\\uFFFD\"})", ); assert!(!js.contains("i18nPostprocess"), "No post-processing expected:\n{js}"); } @@ -6806,24 +6802,24 @@ fn test_i18n_void_name_in_svg_has_a_close_placeholder() { let js = compile_i18n_component(r#"abc"#); assert_contains( &js, - "a${\"\u{FFFD}#3\u{FFFD}\"}:START_TAG__SVG_AREA:b${\"\u{FFFD}/#3\u{FFFD}\"}:CLOSE_TAG__SVG_AREA:c`", + "a${\"\\uFFFD#3\\uFFFD\"}:START_TAG__SVG_AREA:b${\"\\uFFFD/#3\\uFFFD\"}:CLOSE_TAG__SVG_AREA:c`", ); } /// `@if`/`@else` inside an i18n block: each block gets its own start/close placeholder values. /// Angular 22.1.5: -/// { "closeBlockElse": "�/*3:2�", "closeBlockIf": "�/*2:1�", "closeTagSpan": "�/#1:1�", -/// "startBlockElse": "�*3:2�", "startBlockIf": "�*2:1�", "startTagSpan": "�#1:1�" } +/// { "closeBlockElse": "\uFFFD/*3:2\uFFFD", "closeBlockIf": "\uFFFD/*2:1\uFFFD", "closeTagSpan": "\uFFFD/#1:1\uFFFD", +/// "startBlockElse": "\uFFFD*3:2\uFFFD", "startBlockIf": "\uFFFD*2:1\uFFFD", "startTagSpan": "\uFFFD#1:1\uFFFD" } #[test] fn test_i18n_if_else_block_placeholders() { let js = compile_i18n_component(r#"
@if (count) {yes} @else {no}
"#); assert_contains( &js, - "{\"closeBlockElse\":\"\u{FFFD}/*3:2\u{FFFD}\",\"closeBlockIf\":\"\u{FFFD}/*2:1\u{FFFD}\",\"closeTagSpan\":\"\u{FFFD}/#1:1\u{FFFD}\",\"startBlockElse\":\"\u{FFFD}*3:2\u{FFFD}\",\"startBlockIf\":\"\u{FFFD}*2:1\u{FFFD}\",\"startTagSpan\":\"\u{FFFD}#1:1\u{FFFD}\"}", + "{\"closeBlockElse\":\"\\uFFFD/*3:2\\uFFFD\",\"closeBlockIf\":\"\\uFFFD/*2:1\\uFFFD\",\"closeTagSpan\":\"\\uFFFD/#1:1\\uFFFD\",\"startBlockElse\":\"\\uFFFD*3:2\\uFFFD\",\"startBlockIf\":\"\\uFFFD*2:1\\uFFFD\",\"startTagSpan\":\"\\uFFFD#1:1\\uFFFD\"}", ); assert_contains( &js, - "`${\"\u{FFFD}*2:1\u{FFFD}\"}:START_BLOCK_IF:${\"\u{FFFD}#1:1\u{FFFD}\"}:START_TAG_SPAN:yes${\"\u{FFFD}/#1:1\u{FFFD}\"}:CLOSE_TAG_SPAN:${\"\u{FFFD}/*2:1\u{FFFD}\"}:CLOSE_BLOCK_IF:${\"\u{FFFD}*3:2\u{FFFD}\"}:START_BLOCK_ELSE:no${\"\u{FFFD}/*3:2\u{FFFD}\"}:CLOSE_BLOCK_ELSE:`", + "`${\"\\uFFFD*2:1\\uFFFD\"}:START_BLOCK_IF:${\"\\uFFFD#1:1\\uFFFD\"}:START_TAG_SPAN:yes${\"\\uFFFD/#1:1\\uFFFD\"}:CLOSE_TAG_SPAN:${\"\\uFFFD/*2:1\\uFFFD\"}:CLOSE_BLOCK_IF:${\"\\uFFFD*3:2\\uFFFD\"}:START_BLOCK_ELSE:no${\"\\uFFFD/*3:2\\uFFFD\"}:CLOSE_BLOCK_ELSE:`", ); assert!(!js.contains("i18nPostprocess"), "No post-processing expected:\n{js}"); } @@ -6854,7 +6850,7 @@ fn test_i18n_icu_outside_i18n_block() { ); assert_contains( &js, - "(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}));", + "(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\"}));", ); assert_contains(&js, "i0.ɵɵi18n(1,0);"); assert_contains(&js, "i0.ɵɵi18nExp(ctx.gender);"); @@ -6874,7 +6870,7 @@ fn test_i18n_nested_icu_placeholder_order() { ); assert_contains( &js, - "{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\",\"VAR_SELECT_1\":\"\u{FFFD}1\u{FFFD}\",\"VAR_SELECT_2\":\"\u{FFFD}2\u{FFFD}\"}", + "{\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\",\"VAR_SELECT_1\":\"\\uFFFD1\\uFFFD\",\"VAR_SELECT_2\":\"\\uFFFD2\\uFFFD\"}", ); assert_contains(&js, "i0.ɵɵi18nExp(ctx.count)(ctx.count)(ctx.gender);"); } @@ -6889,7 +6885,7 @@ fn test_i18n_icu_element_with_interpolated_attribute() { ); assert_contains( &js, - "{\"CLOSE_TAG_SPAN\":\"\",\"START_TAG_SPAN\":\"\",\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}", + "{\"CLOSE_TAG_SPAN\":\"\",\"START_TAG_SPAN\":\"\",\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\"}", ); assert_contains(&js, "i0.ɵɵi18nExp(ctx.gender)(ctx.name)(ctx.name);"); } @@ -6902,7 +6898,7 @@ fn test_i18n_icu_with_spaces_around_keywords() { assert_contains(&js, "goog.getMsg(\"{VAR_SELECT , select , 3 {three} other {more}}\")"); assert_contains( &js, - "(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\u{FFFD}0\u{FFFD}\"}));", + "(i18n_0 = i0.ɵɵi18nPostprocess(i18n_0,{\"VAR_SELECT\":\"\\uFFFD0\\uFFFD\"}));", ); } @@ -6913,7 +6909,7 @@ fn test_i18n_void_element_placeholder() { let js = compile_i18n_component(r#"
is my logo
"#); assert_contains( &js, - "goog.getMsg(\"{$tagImg} is my logo\",{\"tagImg\":\"\u{FFFD}#2\u{FFFD}\u{FFFD}/#2\u{FFFD}\"})", + "goog.getMsg(\"{$tagImg} is my logo\",{\"tagImg\":\"\\uFFFD#2\\uFFFD\\uFFFD/#2\\uFFFD\"})", ); } @@ -6925,7 +6921,7 @@ fn test_i18n_nested_tag_placeholder_names() { compile_i18n_component(r#"
ainner
"#); assert_contains( &js, - "goog.getMsg(\"{$startTagSpan_1}a{$startTagSpan}inner{$closeTagSpan}{$closeTagSpan}\",{\"closeTagSpan\":\"[\u{FFFD}/#3\u{FFFD}|\u{FFFD}/#2\u{FFFD}]\",\"startTagSpan\":\"\u{FFFD}#3\u{FFFD}\",\"startTagSpan_1\":\"\u{FFFD}#2\u{FFFD}\"})", + "goog.getMsg(\"{$startTagSpan_1}a{$startTagSpan}inner{$closeTagSpan}{$closeTagSpan}\",{\"closeTagSpan\":\"[\\uFFFD/#3\\uFFFD|\\uFFFD/#2\\uFFFD]\",\"startTagSpan\":\"\\uFFFD#3\\uFFFD\",\"startTagSpan_1\":\"\\uFFFD#2\\uFFFD\"})", ); } @@ -6936,7 +6932,7 @@ fn test_i18n_ng_content_placeholder_values() { let js = compile_i18n_component(r#"
tail
"#); assert_contains( &js, - "{\"closeTagNgContent\":\"\u{FFFD}/#2\u{FFFD}\",\"startTagNgContent\":\"\u{FFFD}#2\u{FFFD}\"}", + "{\"closeTagNgContent\":\"\\uFFFD/#2\\uFFFD\",\"startTagNgContent\":\"\\uFFFD#2\\uFFFD\"}", ); } @@ -6945,8 +6941,8 @@ fn test_i18n_ng_content_placeholder_values() { #[test] fn test_i18n_custom_placeholder_name_with_spaces() { let js = compile_i18n_component(r#"
Hi {{ name // i18n(ph = "who") }}
"#); - assert_contains(&js, "goog.getMsg(\"Hi {$who}\",{\"who\":\"\u{FFFD}0\u{FFFD}\"})"); - assert_contains(&js, "$localize `Hi ${\"\u{FFFD}0\u{FFFD}\"}:WHO:`"); + assert_contains(&js, "goog.getMsg(\"Hi {$who}\",{\"who\":\"\\uFFFD0\\uFFFD\"})"); + assert_contains(&js, "$localize `Hi ${\"\\uFFFD0\\uFFFD\"}:WHO:`"); } /// An i18n element with a structural directive keeps its interpolation value. Angular 22.1.5: @@ -6957,7 +6953,7 @@ fn test_i18n_block_on_element_with_structural_directive() { compile_i18n_component(r#"
Some {{ name }}
"#); assert_contains( &js, - "goog.getMsg(\"Some {$startTagSpan}{$interpolation}{$closeTagSpan}\",{\"closeTagSpan\":\"\u{FFFD}/#2\u{FFFD}\",\"interpolation\":\"\u{FFFD}0\u{FFFD}\",\"startTagSpan\":\"\u{FFFD}#2\u{FFFD}\"})", + "goog.getMsg(\"Some {$startTagSpan}{$interpolation}{$closeTagSpan}\",{\"closeTagSpan\":\"\\uFFFD/#2\\uFFFD\",\"interpolation\":\"\\uFFFD0\\uFFFD\",\"startTagSpan\":\"\\uFFFD#2\\uFFFD\"})", ); } @@ -6970,11 +6966,11 @@ fn test_i18n_text_interpolation_next_to_i18n_attribute() { ); assert_contains( &js, - "goog.getMsg(\"{$interpolation}\",{\"interpolation\":\"\u{FFFD}0\u{FFFD}\"})", + "goog.getMsg(\"{$interpolation}\",{\"interpolation\":\"\\uFFFD0\\uFFFD\"})", ); assert_contains( &js, - "goog.getMsg(\"{$interpolation}{$startHeadingLevel1}{$closeHeadingLevel1}\",{\"closeHeadingLevel1\":\"\u{FFFD}/#2\u{FFFD}\",\"interpolation\":\"\u{FFFD}0\u{FFFD}\",\"startHeadingLevel1\":\"\u{FFFD}#2\u{FFFD}\"})", + "goog.getMsg(\"{$interpolation}{$startHeadingLevel1}{$closeHeadingLevel1}\",{\"closeHeadingLevel1\":\"\\uFFFD/#2\\uFFFD\",\"interpolation\":\"\\uFFFD0\\uFFFD\",\"startHeadingLevel1\":\"\\uFFFD#2\\uFFFD\"})", ); } @@ -6997,7 +6993,7 @@ fn test_i18n_svg_element_placeholders() { compile_i18n_component(r#"
Save now
"#); assert_contains( &js, - "$localize `Save ${\"\u{FFFD}#2\u{FFFD}\"}:START_TAG__SVG_SVG:${\"\u{FFFD}#3\u{FFFD}\"}:START_TAG__SVG_CIRCLE:${\"\u{FFFD}/#3\u{FFFD}\"}:CLOSE_TAG__SVG_CIRCLE:${\"\u{FFFD}/#2\u{FFFD}\"}:CLOSE_TAG__SVG_SVG: now`", + "$localize `Save ${\"\\uFFFD#2\\uFFFD\"}:START_TAG__SVG_SVG:${\"\\uFFFD#3\\uFFFD\"}:START_TAG__SVG_CIRCLE:${\"\\uFFFD/#3\\uFFFD\"}:CLOSE_TAG__SVG_CIRCLE:${\"\\uFFFD/#2\\uFFFD\"}:CLOSE_TAG__SVG_SVG: now`", ); } @@ -7017,7 +7013,7 @@ fn test_i18n_root_icu_template_drops_surrounding_whitespace() { #[test] fn test_i18n_message_keeps_ngsp() { let js = compile_i18n_component("
a&ngsp;b
"); - assert_contains(&js, "goog.getMsg(\"a\u{E500}b\")"); + assert_contains(&js, "goog.getMsg(\"a\\uE500b\")"); } /// An ICU placeholder in `$localize` carries the id of its sub-message (Angular's @@ -7065,9 +7061,9 @@ fn test_i18n_literal_placeholder_like_text() { let js = compile_i18n_component(r"
{\\path and {$x} {{ name }}
"); assert_contains( &js, - "goog.getMsg(\"{\\\\path and {$x} {$interpolation}\",{\"interpolation\":\"\u{FFFD}0\u{FFFD}\"})", + "goog.getMsg(\"{\\\\path and {$x} {$interpolation}\",{\"interpolation\":\"\\uFFFD0\\uFFFD\"})", ); - assert_contains(&js, "$localize `{\\\\path and {$x} ${\"\u{FFFD}0\u{FFFD}\"}:INTERPOLATION:`"); + assert_contains(&js, "$localize `{\\\\path and {$x} ${\"\\uFFFD0\\uFFFD\"}:INTERPOLATION:`"); } #[test] @@ -8246,7 +8242,7 @@ export class A { assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); assert!(result.code.contains("const _c0 = [\"x\"];"), "{}", result.code); - assert!(result.code.contains("const _c1 = [\"y\u{85}\"];"), "{}", result.code); + assert!(result.code.contains("const _c1 = [\"y\\u0085\"];"), "{}", result.code); } /// Compatibility guard. Two contracts at once: @@ -8657,9 +8653,9 @@ export class TestComponent { // ============================================================================ #[test] -fn test_unicode_text_not_escaped() { - // Unicode characters like en-dash should be emitted as raw UTF-8, not escaped. - // Angular's TypeScript emitter does NOT escape non-ASCII printable characters. +fn test_unicode_text_escaped_like_ngtsc() { + // ngtsc's TypeScript printer writes text outside ASCII in a string it creates + // as `\uXXXX`, and keeps a literal copied from the source as written. let allocator = Allocator::default(); let source = r" import { Component } from '@angular/core'; @@ -8677,16 +8673,12 @@ export class TestComponent {} assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); let code = &result.code; - // Should contain raw en-dash, not escaped - assert!( - code.contains("\u{2013}") && !code.contains("\\u2013"), - "En-dash should be emitted as raw UTF-8, not as \\u2013. Got:\n{code}" - ); + assert!(code.contains(r#"i0.ɵɵtext(1,"Hello \u2013 World")"#), "Got:\n{code}"); + assert!(code.contains(r"template:'Hello \u{2013} World'"), "Got:\n{code}"); } #[test] -fn test_unicode_non_breaking_space_not_escaped() { - // Non-breaking space (U+00A0) should also be emitted as raw UTF-8 +fn test_unicode_non_breaking_space_escaped_like_ngtsc() { let allocator = Allocator::default(); let source = " import { Component } from '@angular/core'; @@ -8704,11 +8696,8 @@ export class TestComponent {} assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); let code = &result.code; - // Should contain raw non-breaking space, not escaped - assert!( - code.contains("\u{00A0}") && !code.contains("\\u00A0"), - "Non-breaking space should be emitted as raw UTF-8, not as \\u00A0. Got:\n{code}" - ); + assert!(code.contains(r#"i0.ɵɵtext(1,"Hello\u00A0World")"#), "Got:\n{code}"); + assert!(code.contains("template:'Hello\u{00A0}World'"), "Got:\n{code}"); } /// Test that variable naming in embedded view listeners matches Angular TS output. diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index bc242dc98..0780d224a 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -193,8 +193,8 @@ MyDir.ɵdir = i0.ɵɵngDeclareDirective({{ minVersion: "14.0.0", version: "17.0. /// literals. For the aliased array form `[publicName, classPropertyName]` this /// preserves the declaration-format detection — otherwise the array would be /// passed through verbatim and the runtime would read the public name as -/// `InputFlags`. Array elements keep their original source spelling, so only -/// the simple and object forms are asserted byte-identical. +/// `InputFlags`. The names are printed as new string literals, as Angular's +/// linker reads them (`getString()`), so every form is byte-identical. #[test] fn test_link_inputs_template_literal_matches_string_literal() { // Simple value form: same emitted output either way. @@ -209,7 +209,7 @@ fn test_link_inputs_template_literal_matches_string_literal() { let template = link(&allocator, &make_directive_source(r#"prop: [`publicProp`, `prop`]"#), "test.mjs"); assert!( - template.code.contains(r#"prop: [0, `publicProp`, `prop`]"#), + template.code.contains(r#"prop: [0, "publicProp", "prop"]"#), "aliased input lost its InputFlags element:\n{}", template.code ); @@ -220,6 +220,7 @@ fn test_link_inputs_template_literal_matches_string_literal() { "quoted input changed behaviour:\n{}", string.code ); + assert_eq!(template.code, string.code); // Object format: publicName/classPropertyName may also be template literals. let allocator = Allocator::default(); diff --git a/crates/oxc_angular_compiler/tests/lone_surrogate_names_test.rs b/crates/oxc_angular_compiler/tests/lone_surrogate_names_test.rs index a6997ea15..67d68595c 100644 --- a/crates/oxc_angular_compiler/tests/lone_surrogate_names_test.rs +++ b/crates/oxc_angular_compiler/tests/lone_surrogate_names_test.rs @@ -213,7 +213,9 @@ export class K {} #[test] fn private_use_characters_are_printed_as_themselves() { // A real U+10F800 (private use) or U+FDD0 (noncharacter) is not a stored - // lone surrogate, alone or next to each other the other way round. + // lone surrogate, alone or next to each other the other way round. ngtsc + // escapes them like any text outside ASCII (`"a\uDBFE\uDC00"`), and keeps + // the decorator's template as written. let source = "import { Component, Input } from '@angular/core'; @Component({ selector: 'p', template: '\u{10F800}\u{FDD0}' }) export class P { @@ -226,10 +228,11 @@ export class P { assert_has( &compact(&result.code), &[ - "\"a\u{10F800}\":\"a\u{10F800}\"", - "\"b\u{FDD0}\":\"b\u{FDD0}\"", - "\"c\u{10F800}\u{FDD0}\":\"c\u{10F800}\u{FDD0}\"", - "\"\u{10F800}\u{FDD0}\"", + r#":"a\uDBFE\uDC00""#, + r#":"b\uFDD0""#, + r#":"c\uDBFE\uDC00\uFDD0""#, + r#"i0.ɵɵtext(1,"\uDBFE\uDC00\uFDD0")"#, + "template:'\u{10F800}\u{FDD0}'", ], ); assert!(!compact(&result.code).contains(r"\uD800"), "{}", result.code); diff --git a/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs b/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs new file mode 100644 index 000000000..7a53984d6 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs @@ -0,0 +1,404 @@ +//! Text outside ASCII in string literals (#707). +//! +//! ngtsc's output goes through the TypeScript printer. A string literal the +//! compiler creates is printed with text outside ASCII as `\uXXXX` escapes (one +//! per UTF-16 code unit), while a node copied from the source (a +//! `WrappedNodeExpr`: providers, `setClassMetadata` arguments, `@Inject` / +//! `@Attribute` tokens, the NgModule `id`, an inline template in partial output) +//! keeps its source text. The Angular linker builds Babel nodes and its Babel 8 +//! generator prints raw UTF-8, escaping only Unicode spaces. +//! +//! Expected fragments are ngtsc 22.2.4 / Angular linker 22.2.4 output with line +//! breaks and indentation removed. + +use std::collections::HashMap; + +use oxc_allocator::Allocator; +use oxc_angular_compiler::linker::{LinkOptions, link_with_options}; +use oxc_angular_compiler::{ + AngularVersion, CompilationMode, ResolvedResources, TransformOptions, TransformResult, + transform_angular_file, +}; + +fn compile_with( + source: &str, + mode: CompilationMode, + resources: Option<&ResolvedResources>, +) -> TransformResult { + let allocator = Allocator::default(); + let options = TransformOptions { compilation_mode: mode, ..Default::default() }; + let result = transform_angular_file(&allocator, "c.ts", source, Some(&options), resources); + assert!(!result.has_errors(), "unexpected errors: {:?}", result.diagnostics); + result +} + +/// The output with line breaks and the indentation after them removed. +fn flat(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\n' { + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + } else { + out.push(c); + } + } + out +} + +fn full(source: &str) -> String { + flat(&compile_with(source, CompilationMode::Full, None).code) +} + +fn partial(source: &str) -> String { + flat(&compile_with(source, CompilationMode::Partial, None).code) +} + +fn dts(source: &str) -> String { + let result = compile_with(source, CompilationMode::Full, None); + result.dts_declarations.iter().map(|d| d.members.as_str()).collect::>().join("\n") +} + +fn link(code: &str, version: Option) -> String { + let allocator = Allocator::default(); + let options = LinkOptions { angular_version: version, ..Default::default() }; + flat(&link_with_options(&allocator, code, "test.mjs", &options).code) +} + +fn assert_has(haystack: &str, needles: &[&str]) { + for needle in needles { + assert!(haystack.contains(needle), "missing `{needle}` in\n{haystack}"); + } +} + +fn assert_lacks(haystack: &str, needles: &[&str]) { + for needle in needles { + assert!(!haystack.contains(needle), "unexpected `{needle}` in\n{haystack}"); + } +} + +const BASIC: &str = r#"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

ü

' }) export class C {}"#; + +const HOST: &str = r#"import { Component, Input } from '@angular/core'; +@Component({ selector: '[x=é]', template: '', host: { title: 'é', '[attr.y]': "'ü'" }, providers: [{ provide: 'pé', useValue: 'vü' }] }) export class C { @Input('ålias') x; }"#; + +const STYLES: &str = r#"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

x

', styles: ['p::after { content: "é😀"; }'] }) export class C {}"#; + +// ---- full compile ---------------------------------------------------------- + +#[test] +fn template_text_and_attributes_are_escaped() { + let code = full(BASIC); + assert_has( + &code, + &[ + r#"consts:[["title","\u00E9\uD83D\uDE00"]]"#, + r#"i0.ɵɵtext(1,"\u00FC")"#, + // setClassMetadata keeps the decorator as written. + r#"template:'

ü

'"#, + ], + ); +} + +#[test] +fn interpolation_strings_are_escaped_and_template_literals_kept() { + let code = full( + r#"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

{{ \'ß\' }} ñ{{x}}ç

' }) export class C { x = 1; }"#, + ); + assert_has( + &code, + &[ + r#"i0.ɵɵinterpolate1("\u00E4",ctx.x,"\u00F6"))("title","\u00E9")"#, + r#"i0.ɵɵtextInterpolate2("","\u00DF"," \u00F1",ctx.x,"\u00E7")"#, + ], + ); + let code = full( + r"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

{{ `é${x}ü` }}

' }) export class C { x = 1; }", + ); + assert_has(&code, &["i0.ɵɵtextInterpolate(`é${ctx.x}ü`)"]); +} + +#[test] +fn styles_are_escaped() { + let code = full(STYLES); + assert_has( + &code, + &[ + r#"styles:["p[_ngcontent-%COMP%]::after { content: \"\u00E9\uD83D\uDE00\"; }"]"#, + // ngtsc rebuilds the resources in setClassMetadata (`transformDecoratorResources`). + r#"styles:["p::after { content: \"\u00E9\uD83D\uDE00\"; }"]"#, + ], + ); +} + +#[test] +fn i18n_strings_are_escaped_and_localize_text_kept() { + let code = full( + r#"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

héllo {{x}}

' }) export class C { x = 1; }"#, + ); + assert_has( + &code, + &[ + r#"goog.getMsg("t\u00EF")"#, + r#"goog.getMsg("h\u00E9llo {$interpolation}""#, + "tï`", + r#"héllo ${"\uFFFD0\uFFFD"}:INTERPOLATION:`"#, + ], + ); +} + +#[test] +fn host_selector_and_inputs_are_escaped_and_providers_kept() { + let code = full(HOST); + assert_has( + &code, + &[ + r#"selectors:[["","x","\u00E9"]]"#, + r#"hostAttrs:["title","\u00E9"]"#, + r#"i0.ɵɵattribute("y","\u00FC")"#, + r#"inputs:{x:[0,"\u00E5lias","x"]}"#, + "i0.ɵɵProvidersFeature([{provide:'pé',useValue:'vü'}])", + r#"selector:'[x=é]'"#, + ], + ); + let dts = dts(HOST); + assert_has(&dts, &[r#""[x=\u00E9]""#, r#""alias": "\u00E5lias""#]); +} + +#[test] +fn external_template_is_escaped_in_metadata() { + let source = r"import { Component } from '@angular/core'; +@Component({ selector: 'c', templateUrl: './c.html' }) export class C {}"; + let templates = + HashMap::from([("./c.html".to_string(), r#"é😀"#.to_string())]); + let resources = ResolvedResources { templates, styles: HashMap::new() }; + let code = flat(&compile_with(source, CompilationMode::Full, Some(&resources)).code); + assert_has( + &code, + &[ + r#"consts:[["title","\u00F1"]]"#, + r#"i0.ɵɵtext(1,"\u00E9\uD83D\uDE00")"#, + r#"template:"\u00E9\uD83D\uDE00""#, + ], + ); + let code = flat(&compile_with(source, CompilationMode::Partial, Some(&resources)).code); + assert_has(&code, &[r#"template:"\u00E9\uD83D\uDE00""#]); +} + +#[test] +fn signal_debug_name_is_escaped() { + let code = full( + r"import { Component, signal, input } from '@angular/core'; +@Component({ selector: 'c', template: '' }) export class Café { naïve = signal(0); ünput = input(); }", + ); + assert_has( + &code, + &[ + r#"debugName: "na\u00EFve""#, + r#"debugName: "\u00FCnput""#, + r#"inputs:{ünput:[1,"\u00FCnput"]}"#, + ], + ); +} + +#[test] +fn attribute_and_inject_string_tokens_keep_source_text() { + // Directive path. + let source = r"import { Directive, Attribute, Inject } from '@angular/core'; +@Directive({ selector: '[d]' }) export class D { constructor(@Attribute('é') a: string, @Inject('tök') t: any) {} }"; + assert_has(&full(source), &["i0.ɵɵinjectAttribute('é')", "i0.ɵɵdirectiveInject('tök')"]); + assert_has(&dts(source), &[r#"attribute: "\u00E9""#]); + + // Component path. + let source = r"import { Component, Attribute, Inject, Optional } from '@angular/core'; +@Component({ selector: 'c', template: '' }) export class C { constructor(@Attribute('é') a: string, @Inject('tök') t: any, @Optional() @Inject('ö') o: any) {} }"; + assert_has( + &full(source), + &["i0.ɵɵinjectAttribute('é'),i0.ɵɵdirectiveInject('tök'),i0.ɵɵdirectiveInject('ö',8)"], + ); + // ngtsc: `deps: [{ token: 'é', attribute: true }, { token: 'tök' }, { token: 'ö', optional: true }]` + assert_has( + &partial(source), + &["deps:[{token:'é',attribute:true},{token:'tök'},{token:'ö',optional:true}]"], + ); +} + +#[test] +fn ng_module_id_keeps_source_text() { + let source = r"import { NgModule } from '@angular/core'; +@NgModule({ id: 'mé' }) export class M {}"; + assert_has(&full(source), &["id:'mé'", "i0.ɵɵregisterNgModuleType(M,'mé')"]); + assert_has(&partial(source), &["id:'mé'"]); +} + +#[test] +fn quoted_keys_in_source_objects_keep_source_text() { + let code = full( + r#"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '', providers: [{ provide: 'x', useValue: { 'é-x': 'a', café: 'b', "q": 'é' } }] }) export class C {}"#, + ); + // ngtsc: `useValue: { 'é-x': 'a', café: 'b', "q": 'é' }` (ASCII literals + // are re-quoted, as before). + assert_has(&code, &[r#"useValue:{'é-x':"a",café:"b","q":'é'}"#]); + + let code = full( + r"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '', host: { '(é)': 'f()', 'class': 'é', '[class.é]': 'x' } }) export class C { x; f() {} }", + ); + assert_has(&code, &[r#"host:{'(é)':"f()","class":'é','[class.é]':"x"}"#]); +} + +#[test] +fn added_import_lines_escape_the_module_specifier() { + let code = full( + r"import { Component } from '@angular/core'; +import { Svc } from './sé'; +@Component({ selector: 'c', template: '' }) export class C { constructor(s: Svc) {} }", + ); + // ngtsc: `import * as i1 from "./s\u00E9";` (the quote style differs, as before). + assert_has(&code, &[r"import * as i1 from './s\u00E9';", "i0.ɵɵdirectiveInject(i1.Svc)"]); +} + +// ---- partial compile ------------------------------------------------------- + +#[test] +fn partial_inline_template_keeps_source_text() { + assert_has(&partial(BASIC), &[r#"template:'

ü

',isInline:true"#]); + let code = partial( + r"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: `

é

` }) export class C {}", + ); + assert_has(&code, &["template:`

é

`,isInline:true"]); + let code = partial( + r"import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '

é

' }) export class C {}", + ); + assert_has(&code, &[r"template:'

é

',isInline:true"]); + // A template read from a constant is a new literal. + let code = partial( + r"import { Component } from '@angular/core'; +const T = '

é

'; +@Component({ selector: 'c', template: T }) export class C {}", + ); + assert_has(&code, &[r#"template:"

\u00E9

",isInline:true"#]); +} + +#[test] +fn partial_declaration_strings_are_escaped() { + let code = partial(HOST); + assert_has( + &code, + &[ + r#"selector:"[x=\u00E9]""#, + r#"inputs:{x:["\u00E5lias","x"]}"#, + r#"host:{attributes:{"title":"\u00E9"},properties:{"attr.y":"'\u00FC'"}}"#, + "providers:[{provide:'pé',useValue:'vü'}]", + ], + ); + let code = partial(STYLES); + assert_has(&code, &[r#"styles:["p::after { content: \"\u00E9\uD83D\uDE00\"; }"]"#]); +} + +// ---- linker ---------------------------------------------------------------- + +/// ngtsc 22.2.4's partial output for [`BASIC`]. +const NGTSC_PARTIAL_BASIC: &str = r#"import * as i0 from "@angular/core"; +export class C { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.2", ngImport: i0, type: C, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: C, isStandalone: true, selector: "c", ngImport: i0, template: '

ü

', isInline: true }); +}"#; + +#[test] +fn linker_prints_raw_text_like_babel() { + // Angular linker: `consts: [["title", "é😀"]]`, `i0.ɵɵtext(1, "ü")`. + let code = link(NGTSC_PARTIAL_BASIC, None); + assert_has(&code, &[r#"consts: [["title","é😀"]]"#, r#"i0.ɵɵtext(1,"ü")"#]); + assert_lacks(&code, &[r"\u00"]); +} + +#[test] +fn linker_escapes_unicode_spaces_like_babel() { + // Angular linker: `consts: [["title", "b\u2028c", "id", "a\xA0b"]]`. + let partial = r#"import * as i0 from "@angular/core"; +export class C { + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: C, isStandalone: true, selector: "c", ngImport: i0, template: '

x

', isInline: true }); +}"#; + let code = link(partial, None); + assert_has(&code, &[r#"consts: [["title","b\u2028c","id","a\xA0b"]]"#]); +} + +#[test] +fn linker_reads_declaration_strings_like_angular() { + // Names are read with `getString()` and printed as new literals (raw); + // host attribute values and providers are copied from the source. + let partial = r#"import * as i0 from "@angular/core"; +export class C { + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.2", type: C, isStandalone: true, selector: "[x=\u00E9]", inputs: { x: ["\u00E5lias", "x"] }, outputs: { öut: "\u00F6ut" }, host: { attributes: { "title": "\u00E9" }, properties: { "attr.y": "'\u00FC'" }, classAttribute: "\u00E9" }, providers: [{ provide: 'pé', useValue: 'vü' }], exportAs: ["\u00E9"], ngImport: i0, template: '', isInline: true }); +}"#; + let code = link(partial, None); + // Angular linker 22.2.4 (Babel 8): `selectors: [["", "x", "é"]]`, + // `hostAttrs: ["title", "\u00E9", 1, "é"]`, `i0.ɵɵattribute("y", "ü")`, + // `inputs: { x: [0, "ålias", "x"] }`, `outputs: { öut: "öut" }`, + // `exportAs: ["é"]`, `i0.ɵɵProvidersFeature([{ provide: 'pé', useValue: 'vü' }])`. + assert_has( + &code, + &[ + r#"selectors: [["", "x", "é"]]"#, + r#"hostAttrs: ["title", "\u00E9", 1, "é"]"#, + r#"i0.ɵɵattribute("y","ü")"#, + r#"inputs: { x: [0, "ålias", "x"] }"#, + r#"outputs: { öut: "öut" }"#, + r#"exportAs: ["é"]"#, + "i0.ɵɵProvidersFeature([{ provide: 'pé', useValue: 'vü' }])", + ], + ); +} + +// ---- linker changeDetection ------------------------------------------------- + +fn declare_component(version: &str, change_detection: &str) -> String { + format!( + r#"import * as i0 from "@angular/core"; +export class C {{ + static ɵcmp = i0.ɵɵngDeclareComponent({{ minVersion: "14.0.0", version: "{version}", type: C, isStandalone: true, selector: "c", ngImport: i0, template: '', isInline: true{change_detection} }}); +}}"# + ) +} + +const ON_PUSH: &str = ", changeDetection: i0.ChangeDetectionStrategy.OnPush"; +const DEFAULT: &str = ", changeDetection: i0.ChangeDetectionStrategy.Default"; + +/// The 19.2.15, 20.1.8, 20.2.0 and 21.2.0 linkers default a missing field to +/// `ChangeDetectionStrategy.Default`, and their compiler only writes a strategy +/// other than `Default`: no field for a missing or `Default` value, `0` for OnPush. +#[test] +fn pre_v22_linker_leaves_out_the_default_strategy() { + for (major, minor, patch) in [(19, 2, 15), (20, 1, 8), (20, 2, 0), (21, 2, 0)] { + let linker = Some(AngularVersion::new(major, minor, patch)); + let declared = format!("{major}.{minor}.{patch}"); + let code = link(&declare_component(&declared, ""), linker); + assert_lacks(&code, &["changeDetection"]); + let code = link(&declare_component(&declared, DEFAULT), linker); + assert_lacks(&code, &["changeDetection"]); + let code = link(&declare_component(&declared, ON_PUSH), linker); + assert_has(&code, &["changeDetection: 0"]); + } +} + +/// The 22.2.4 linker reads a pre-v22 declaration with no field as Eager. +#[test] +fn v22_linker_keeps_eager_for_pre_v22_declarations() { + let linker = Some(AngularVersion::new(22, 2, 4)); + assert_has(&link(&declare_component("20.1.8", ""), linker), &["changeDetection: 1"]); + assert_has(&link(&declare_component("20.1.8", DEFAULT), linker), &["changeDetection: 1"]); + assert_lacks(&link(&declare_component("22.2.4", ""), linker), &["changeDetection"]); + // With no app version, the linker keeps the latest behaviour. + assert_has(&link(&declare_component("20.1.8", ""), None), &["changeDetection: 1"]); +} From 612de7e24df51f9d20c7602dfec446e0e51bded0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:31:48 +0800 Subject: [PATCH 13/22] test(napi): expect the escaped trigger name in syntheticHostListener (#707) ngtsc 22.2.4 prints the trigger string as "@\u03BCAnim.start". Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/test/transform.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/napi/angular-compiler/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 0b2edc8d4..0c85b3c34 100644 --- a/napi/angular-compiler/test/transform.test.ts +++ b/napi/angular-compiler/test/transform.test.ts @@ -536,9 +536,10 @@ describe('animation host listeners', () => { ` const result = await transformAngularFile(source, 'test.component.ts', {}) expect(result.errors).toHaveLength(0) - // The literal in syntheticHostListener preserves the trigger as-is (Angular - // does not sanitize it — the @ + trigger + . + phase string is the runtime key) - expect(result.code).toContain('ɵɵsyntheticHostListener("@μAnim.start"') + // The literal in syntheticHostListener keeps the trigger (Angular does not + // sanitize it — the @ + trigger + . + phase string is the runtime key). Like + // ngtsc's printer, the μ is written as an escape. + expect(result.code).toContain('ɵɵsyntheticHostListener("@\\u03BCAnim.start"') // The handler function name MUST sanitize μ → _ (JS \W matches non-ASCII) expect(result.code).toContain('TestComponent_animation__Anim_start_HostBindingHandler') expect(result.code).not.toContain('TestComponent_animation_μAnim_start') From acfe27bb92db09f62bc0a21322592f135645465a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:44:19 +0800 Subject: [PATCH 14/22] fix(ng-module): wrap lists that name an import after the module, read nested package tuples (#703) ngtsc wraps an NgModule's lists in `() => [...]` when a name it writes sits after the class. For an import that name is the one written in a variable read for the list, so a list, alias or `{ ngModule }` written after the NgModule (reached directly or through `forwardRef`) is wrapped. Oxc only did this for a local class declared later. A package's `declare const` tuple type may hold a nested tuple or named members. ngtsc's evaluator reads them, so Oxc reads them too instead of keeping the whole list as written. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 46 ++++++- .../src/ng_module/module_with_providers.rs | 124 ++++++++++------- .../tests/ng_module_list_reading_test.rs | 126 ++++++++++++++++++ 3 files changed, 250 insertions(+), 46 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs index 7d81e59b8..46c611dd4 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs @@ -1343,7 +1343,9 @@ fn read_declared<'a>( ty = &operator.type_annotation; } let TSType::TSTupleType(_) = ty else { return None }; - return Some(Declared::TypeList(type_list(ty, &decl.file)?)); + let mut refs = Vec::new(); + const_type_list(ty, &decl.file, &mut refs)?; + return Some(Declared::TypeList(refs)); }; let mut refs = Vec::new(); collect_refs(init, consts, &decl.file, &mut refs, 0, false, false)?; @@ -1401,6 +1403,48 @@ fn read_declaration_type(class: &Class<'_>, file: &str) -> Option { }) } +/// The classes a declared `const`'s tuple type names, nested tuples +/// flattened, read like ngtsc's evaluator (`visitType`): `typeof A`, a +/// tuple, a `readonly` one, or a named member (`name: typeof A`). +fn const_type_list(ty: &TSType<'_>, file: &str, out: &mut Vec) -> Option<()> { + const MAX_NESTING: u8 = 8; + fn read(ty: &TSType<'_>, file: &str, out: &mut Vec, depth: u8) -> Option<()> { + if depth > MAX_NESTING { + return None; + } + match ty { + TSType::TSTypeQuery(query) => { + out.push(type_name_ref(file, query.expr_name.as_ts_type_name()?)?); + Some(()) + } + TSType::TSTupleType(tuple) => tuple + .element_types + .iter() + .try_for_each(|element| read_element(element, file, out, depth)), + TSType::TSTypeOperatorType(operator) + if operator.operator == TSTypeOperatorOperator::Readonly => + { + read(&operator.type_annotation, file, out, depth + 1) + } + _ => None, + } + } + fn read_element( + element: &TSTupleElement<'_>, + file: &str, + out: &mut Vec, + depth: u8, + ) -> Option<()> { + match element { + TSTupleElement::TSNamedTupleMember(member) => { + read_element(&member.element_type, file, out, depth + 1) + } + _ => read(element.as_ts_type()?, file, out, depth + 1), + } + } + read(ty, file, out, 0) +} + /// The classes a `[typeof A, typeof i1.B]` tuple type names; `never` names /// none. fn type_list(ty: &TSType<'_>, file: &str) -> Option> { diff --git a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs index 3ae6afbce..ca2253030 100644 --- a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs +++ b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs @@ -21,8 +21,9 @@ //! lists the classes there too). A full-mode list with an entry Oxc can't //! read stays as written: the runtime flattens it. The injector keeps the //! raw entries (it needs their providers). In both modes, the lists are -//! wrapped in `() => [...]` like ngtsc does: when one names a class this -//! file declares after the NgModule. +//! wrapped in `() => [...]` like ngtsc does: when a name they list is +//! written after the NgModule (a class this file declares later, or an +//! import listed in a variable written later). //! //! A top-level entry that can't be read keeps its old output. This file //! reads what one file can tell. With cross-file analysis, a name of @@ -280,13 +281,16 @@ pub fn top_level_const_init<'b, 'a>( program: &'b Program<'a>, name: &str, ) -> Option<&'b Expression<'a>> { - top_level_init(program, name, false) + top_level_init(program, name, false).map(|(init, _)| init) } /// The initializer of the top-level `const`, `let` or `var` `name` of -/// `program`. ngtsc's evaluator reads the initializer of any variable -/// (`getVariableValue`). -fn top_level_var_init<'b, 'a>(program: &'b Program<'a>, name: &str) -> Option<&'b Expression<'a>> { +/// `program`, and where its declaration starts. ngtsc's evaluator reads +/// the initializer of any variable (`getVariableValue`). +fn top_level_var_init<'b, 'a>( + program: &'b Program<'a>, + name: &str, +) -> Option<(&'b Expression<'a>, u32)> { top_level_init(program, name, true) } @@ -294,7 +298,7 @@ fn top_level_init<'b, 'a>( program: &'b Program<'a>, name: &str, any_kind: bool, -) -> Option<&'b Expression<'a>> { +) -> Option<(&'b Expression<'a>, u32)> { use oxc_ast::ast::VariableDeclarationKind; program.body.iter().find_map(|stmt| { let declaration = match stmt { @@ -310,11 +314,11 @@ fn top_level_init<'b, 'a>( if !read { return None; } - vars.declarations + let var = vars + .declarations .iter() - .find(|var| var.id.get_binding_identifier().is_some_and(|id| id.name == name))? - .init - .as_ref() + .find(|var| var.id.get_binding_identifier().is_some_and(|id| id.name == name))?; + Some((var.init.as_ref()?, var.span.start)) }) } @@ -457,9 +461,9 @@ pub enum ListKey { pub struct ModuleLists { lists: [Vec; 3], /// Whether the lists must be read lazily (`() => [...]`): one names a - /// class of this file declared after the NgModule (ngtsc's - /// `isExpressionForwardReference`), or a `forwardRef` target that may - /// not be initialized yet. + /// class of this file declared after the NgModule, or lists an import + /// by a name written after it (ngtsc's `isExpressionForwardReference`), + /// or a `forwardRef` target that may not be initialized yet. pub wrap: bool, } @@ -498,8 +502,15 @@ pub fn read_module_lists( (crate::util::is_metadata_property(property) && key == name).then_some(&property.value) }) }; - let mut walker = - Walker { program, module: class, binding, resolve_imported, wrap: false, unsure: false }; + let mut walker = Walker { + program, + module: class, + binding, + resolve_imported, + wrap: false, + unsure: false, + late: false, + }; let mut lists = ["bootstrap", "declarations", "imports", "exports"] .into_iter() .zip(current) @@ -688,6 +699,12 @@ struct Walker<'p, 'a, 'r, B> { /// decorator parser wraps every list once one writes a `forwardRef`, /// so the wrap stays then. unsure: bool, + /// Set while reading the initializer of a variable (or the method of a + /// class) written after the NgModule. A name ngtsc writes as this file + /// writes it there comes after the NgModule, so ngtsc wraps the lists + /// (`isExpressionForwardReference` looks at where the written name + /// sits). + late: bool, } impl Binding> Walker<'_, '_, '_, B> { @@ -747,10 +764,10 @@ impl Binding> Walker<'_, '_, '_, B> { match evaluated(expr) { Expression::ArrayExpression(array) => self.array(array, depth), Expression::Identifier(id) => match top_level_var_init(self.program, &id.name) { - Some(init) => match evaluated(init) { - Expression::ArrayExpression(array) => self.array(array, depth + 1), + Some((init, start)) => self.read_at(start, |this| match evaluated(init) { + Expression::ArrayExpression(array) => this.array(array, depth + 1), _ => None, - }, + }), None => self.ask_import(&id.name, true), }, expr => { @@ -801,20 +818,29 @@ impl Binding> Walker<'_, '_, '_, B> { if self.local_class(name) { return Some(vec![Ref::Local(name.into())]); } - let Some(init) = top_level_var_init(self.program, name) else { + let Some((init, start)) = top_level_var_init(self.program, name) else { return self.ask_import(name, false); }; - match evaluated(init) { - Expression::ArrayExpression(array) => self.array(array, depth + 1), - Expression::Identifier(alias) => self.name(&alias.name, depth + 1), + self.read_at(start, |this| match evaluated(init) { + Expression::ArrayExpression(array) => this.array(array, depth + 1), + Expression::Identifier(alias) => this.name(&alias.name, depth + 1), init => match module_with_providers_init(init)? { MwpInit::Call { namespace, class, method } => { - self.mwp_call(namespace, class, method) + this.mwp_call(namespace, class, method) } - MwpInit::Module(Expression::Identifier(id)) => self.class_name(&id.name), + MwpInit::Module(Expression::Identifier(id)) => this.class_name(&id.name), MwpInit::Module(_) => None, }, - } + }) + } + + /// Run `read` with [`Self::late`] set by whether `start`, where what it + /// reads is written, comes after the NgModule. + fn read_at(&mut self, start: u32, read: impl FnOnce(&mut Self) -> T) -> T { + let late = std::mem::replace(&mut self.late, start > self.module.span.start); + let result = read(self); + self.late = late; + result } /// `class.method(...)` or `namespace.class.method(...)`. @@ -825,26 +851,27 @@ impl Binding> Walker<'_, '_, '_, B> { method: &str, ) -> Option> { let program = self.program; - match namespace { - None if top_level_class(program, class).is_some() => { - self.class_name(method_module(program, class, method)?) - } - None => { - if !matches!((self.binding)(class), Binding::Import { .. }) { - return None; - } - let question = - Question { namespace: None, name: class, method: Some(method), spread: false }; - let answer = self.ask(&question)?; - self.classes(answer) - } - Some(namespace) => self.ask_namespace(namespace, class, Some(method), false), + if let Some(namespace) = namespace { + return self.ask_namespace(namespace, class, Some(method), false); + } + if let Some(declared) = top_level_class(program, class) { + // The NgModule is named in the method, where the class is. + let module = method_module(program, class, method)?; + return self.read_at(declared.span.start, |this| this.class_name(module)); + } + if !matches!((self.binding)(class), Binding::Import { .. }) { + return None; } + let question = + Question { namespace: None, name: class, method: Some(method), spread: false }; + let answer = self.ask(&question)?; + self.classes(answer) } /// `forwardRef(fn)`: what `fn` returns, read like any entry (ngtsc's - /// `createForwardRefResolver`). ngtsc wraps the lists only for a class - /// of this file declared after the NgModule. Oxc also keeps the wrap + /// `createForwardRefResolver`). ngtsc wraps the lists only when a name + /// it writes sits after the NgModule (see `Walker::local_class` and + /// `Walker::late`). Oxc also keeps the wrap /// when the target is unread, or may not be defined yet as the NgModule /// is (its file loads this one back): the full-mode scope runs with no /// `ngJitMode` guard, so reading it early would throw. @@ -871,7 +898,10 @@ impl Binding> Walker<'_, '_, '_, B> { match self.ask(&question)? { Answer::AsWritten { unready } => { self.unsure |= unready; - (kept && !spread).then(|| vec![Ref::Local(name.into())]) + let listed = kept && !spread; + // Listed by the name written here; see `Walker::late`. + self.wrap |= listed && self.late; + listed.then(|| vec![Ref::Local(name.into())]) } answer @ Answer::Classes { .. } => self.classes(answer), } @@ -914,8 +944,12 @@ impl Binding> Walker<'_, '_, '_, B> { /// A class this file declares or a kept import, listed by `name`. fn class_name(&mut self, name: &str) -> Option> { - let listed = self.local_class(name) - || matches!((self.binding)(name), Binding::Import { kept: true }); + if self.local_class(name) { + return Some(vec![Ref::Local(name.into())]); + } + let listed = matches!((self.binding)(name), Binding::Import { kept: true }); + // Listed by the name written here; see `Walker::late`. + self.wrap |= listed && self.late; listed.then(|| vec![Ref::Local(name.into())]) } diff --git a/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs index bda72d687..102fee28d 100644 --- a/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs +++ b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs @@ -47,6 +47,8 @@ export declare class LibTwoModule { static ɵinj: i0.ɵɵInjectorDeclaration; } export declare const LIB_ALL: [typeof LibModule, typeof LibTwoModule]; +export declare const LIB_NESTED: [typeof LibModule, [typeof LibTwoModule]]; +export declare const LIB_NAMED: [first: typeof LibModule, rest: [typeof LibTwoModule]]; "; fn write(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { @@ -292,6 +294,130 @@ import * as d2 from './dep2'; ); } +#[test] +fn a_package_tuple_with_nested_tuples() { + // ngtsc's evaluator reads a nested tuple type and a named member too. + check( + &format!( + "{H}import * as lib from 'fakelib'; +import * as dep from './dep'; +@NgModule({{ imports: [lib.LIB_NESTED], exports: [dep.DepModule] }}) export class M {{}}" + ), + "import*asi1from'fakelib';import*asi2from'./dep';", + "{imports:[i1.LibModule,i1.LibTwoModule],exports:[i2.DepModule]}", + "imports:[i1.LibModule,i1.LibTwoModule],exports:[i2.DepModule]", + "never,[typeofi1.LibModule,typeofi1.LibTwoModule],[typeofi2.DepModule]", + ); + check( + &format!( + "{H}import {{ LIB_NAMED }} from 'fakelib'; +@NgModule({{ imports: [...LIB_NAMED] }}) export class M {{}}" + ), + "import*asi1from'fakelib';", + "{imports:[i1.LibModule,i1.LibTwoModule]}", + "imports:[i1.LibModule,i1.LibTwoModule]", + "never,[typeofi1.LibModule,typeofi1.LibTwoModule],never", + ); +} + +#[test] +fn an_import_listed_in_a_variable_written_after_the_module_is_wrapped() { + // ngtsc wraps the lists when the name it writes sits after the + // NgModule: here the import's name inside the later variable. + let wrapped = [ + "@NgModule({ imports: [VLIST] }) export class M {} +var VLIST = [DepModule];", + "@NgModule({ imports: [forwardRef(() => MODS)] }) export class M {} +const MODS = [DepModule];", + "@NgModule({ imports: [forwardRef(() => ALIAS)] }) export class M {} +const ALIAS = DepModule;", + "@NgModule({ imports: [forwardRef(() => P)] }) export class M {} +const P = { ngModule: DepModule, providers: [] };", + "const EARLY = [forwardRef(() => LATE)]; +@NgModule({ imports: EARLY }) export class M {} +const LATE = DepModule;", + ]; + for source in wrapped { + check( + &format!("{H}import {{ DepModule }} from './dep';\n{source}"), + "import*asi0from'@angular/core';", + "{imports:()=>[DepModule]}", + "imports:()=>[DepModule]", + "never,[typeofi1.DepModule],never", + ); + } + check( + &format!( + "{H}import {{ DepModule, OtherModule }} from './dep'; +@NgModule({{ imports: [forwardRef(() => MODS)], exports: [OtherModule] }}) export class M {{}} +const MODS = [DepModule];" + ), + "import*asi0from'@angular/core';", + "{imports:()=>[DepModule],exports:()=>[OtherModule]}", + "imports:()=>[DepModule],exports:()=>[OtherModule]", + "never,[typeofi1.DepModule],[typeofi1.OtherModule]", + ); + check( + &format!( + "{H}import {{ DepModule }} from './dep'; +@NgModule({{ exports: [forwardRef(() => LLIST)] }}) export class M {{}} +let LLIST = [DepModule];" + ), + "import*asi0from'@angular/core';", + "{exports:()=>[DepModule]}", + "exports:()=>[DepModule]", + "never,never,[typeofi1.DepModule]", + ); +} + +#[test] +fn a_name_written_before_the_module_is_not_wrapped() { + // The later variable leads back to a name written earlier, or to a + // namespace member ngtsc names through its own import. + let plain = [ + "const EARLY = [DepModule]; +@NgModule({ imports: [forwardRef(() => L)] }) export class M {} +const L = [...EARLY];", + "const EARLY = DepModule; +@NgModule({ imports: [forwardRef(() => L)] }) export class M {} +const L = EARLY;", + "export class Holder { static forRoot() { return { ngModule: DepModule, providers: [] }; } } +@NgModule({ imports: [forwardRef(() => P)] }) export class M {} +const P = Holder.forRoot();", + ]; + for source in plain { + check( + &format!("{H}import {{ DepModule }} from './dep';\n{source}"), + "import*asi0from'@angular/core';", + "{imports:[DepModule]}", + "imports:[DepModule]", + "never,[typeofi1.DepModule],never", + ); + } + check( + &format!( + "{H}import * as dep from './dep'; +@NgModule({{ imports: [forwardRef(() => LATE)] }}) export class M {{}} +const LATE = [dep.DepModule];" + ), + "import*asi1from'./dep';", + "{imports:[i1.DepModule]}", + "imports:[i1.DepModule]", + "never,[typeofi1.DepModule],never", + ); + check( + &format!( + "{H}@NgModule({{}}) export class A {{}} +@NgModule({{ imports: [forwardRef(() => L)] }}) export class M {{}} +const L = [A];" + ), + "import*asi0from'@angular/core';", + "{imports:[A]}", + "imports:[A]", + "never,[typeofA],never", + ); +} + #[test] fn module_with_providers_calls() { // The NgModule each call gives, named through the file that declares From 1403a474d732570dccd7f85826f3dabfa58aea6a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 21:57:37 +0800 Subject: [PATCH 15/22] fix: print host directive names and JIT resource imports like Angular (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker now rebuilds each hostDirectives input and output name as a new string, as the Angular linker reads them with getString(). So "é" from the partial file comes out as "é", like Babel 8 prints it. The rest of hostDirectives keeps its source text. The JIT resource imports (angular:jit:template:file;...) now escape text outside ASCII in the specifier, as the Angular CLI builds them with createStringLiteral and the TypeScript printer escapes them. In JIT mode the whole file is printed again, which writes the text raw, so the lines are fixed after that step. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 30 +++++++++++++-- crates/oxc_angular_compiler/src/linker/mod.rs | 38 ++++++++++++++++++- .../tests/non_ascii_output_test.rs | 38 +++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c9890a06a..497fb00c2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2456,6 +2456,16 @@ fn build_jit_decorator_text<'a>( apply_jit_decorator_text_edits(expr_text, edits) } +/// The import line for a JIT resource (`angular:jit:template:file;./a.html`). +/// The Angular CLI builds the specifier with `factory.createStringLiteral`, so +/// the TypeScript printer escapes text outside ASCII (`./t\u00E9.html`). +fn jit_resource_import(import_name: &str, specifier: &str) -> String { + format!( + "import {import_name} from {};\n", + crate::output::emitter::escape_string(specifier, false) + ) +} + /// `text` with `edits` (offsets into it) applied. fn apply_jit_decorator_text_edits( text: &str, @@ -3292,7 +3302,7 @@ fn transform_angular_file_jit( .push_str(&format!("import * as {core_namespace} from \"@angular/core\";\n")); } for (import_name, specifier) in &resource_imports { - additional_imports.push_str(&format!("import {} from \"{}\";\n", import_name, specifier)); + additional_imports.push_str(&jit_resource_import(import_name, specifier)); } // Insert additional imports after the last existing import @@ -3362,6 +3372,21 @@ fn transform_angular_file_jit( // 5. Strip TypeScript syntax from JIT output result.code = strip_typescript(allocator, path, &result.code); + // The printer above writes the resource specifiers as raw text; the Angular + // CLI's TypeScript printer escapes text outside ASCII in them. + for (import_name, specifier) in &resource_imports { + if specifier.is_ascii() { + continue; + } + let printed = format!("import {import_name} from \"{specifier}\";\n"); + if let Some(start) = result.code.find(&printed) { + result.code.replace_range( + start..start + printed.len(), + &jit_resource_import(import_name, specifier), + ); + } + } + result } @@ -5376,8 +5401,7 @@ pub fn transform_angular_file( // registry below under whatever alias the file allows (`i0` or a // uniquified `i0_1`, ...), for every alias shape. for (import_name, specifier) in &jit_resource_imports { - additional_imports - .push_str(&format!("import {} from \"{}\";\n", import_name, specifier)); + additional_imports.push_str(&jit_resource_import(import_name, specifier)); } } diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index b7ab76a5b..c5112c0ba 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1747,6 +1747,42 @@ fn link_export_as(meta: &ObjectExpression<'_>, source: &str) -> Option { Some(names.map_or_else(|| as_written.to_string(), |names| format!("[{}]", names.join(", ")))) } +/// A declaration's `hostDirectives` for `ɵɵHostDirectivesFeature`: the source +/// text, but with each `inputs` and `outputs` name printed as a new string, as +/// the Angular linker reads them with `getString()` +/// (`getHostDirectiveBindingMapping`). +fn link_host_directives(meta: &ObjectExpression<'_>, source: &str) -> Option { + let as_written = get_property_source(meta, "hostDirectives", source)?; + let Some(entries) = get_array_property(meta, "hostDirectives") else { + return Some(as_written.to_string()); + }; + let start = entries.span.start as usize; + let mut names: Vec<(Span, String)> = Vec::new(); + for entry in &entries.elements { + let Some(Expression::ObjectExpression(entry)) = entry.as_expression() else { + continue; + }; + for field in ["inputs", "outputs"] { + let Some(mapping) = get_array_property(entry, field) else { continue }; + for name in &mapping.elements { + let Some(name) = name.as_expression() else { continue }; + if let Some(literal) = static_string_literal(name, source) { + names.push((name.span(), literal)); + } + } + } + } + let mut linked = String::with_capacity(as_written.len()); + let mut copied = start; + for (span, literal) in names { + linked.push_str(&source[copied..span.start as usize]); + linked.push_str(&literal); + copied = span.end as usize; + } + linked.push_str(&source[copied..entries.span.end as usize]); + Some(linked) +} + /// Link ɵɵngDeclareDirective → ɵɵdefineDirective. fn link_directive<'a>( meta: &ObjectExpression<'_>, @@ -2251,7 +2287,7 @@ fn build_features( } // 2. HostDirectivesFeature — must come before InheritDefinitionFeature - if let Some(host_directives) = get_property_source(meta, "hostDirectives", source) { + if let Some(host_directives) = link_host_directives(meta, source) { features.push(format!("{ns}.\u{0275}\u{0275}HostDirectivesFeature({host_directives})")); } diff --git a/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs b/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs index 7a53984d6..46f21c9cc 100644 --- a/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs +++ b/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs @@ -266,6 +266,24 @@ import { Svc } from './sé'; assert_has(&code, &[r"import * as i1 from './s\u00E9';", "i0.ɵɵdirectiveInject(i1.Svc)"]); } +#[test] +fn jit_resource_imports_escape_the_specifier() { + let allocator = Allocator::default(); + let options = TransformOptions { jit: true, ..Default::default() }; + let source = r"import { Component } from '@angular/core'; +@Component({ selector: 'd', templateUrl: './té.html', styleUrls: ['./sé.css'] }) export class Y {}"; + let result = transform_angular_file(&allocator, "c.ts", source, Some(&options), None); + assert!(!result.has_errors(), "unexpected errors: {:?}", result.diagnostics); + // @angular/build 22.2.6 `createJitResourceTransformer` + TypeScript printer. + assert_has( + &result.code, + &[ + r#"import __NG_CLI_RESOURCE__0 from "angular:jit:template:file;./t\u00E9.html";"#, + r#"import __NG_CLI_RESOURCE__1 from "angular:jit:style:file;./s\u00E9.css";"#, + ], + ); +} + // ---- partial compile ------------------------------------------------------- #[test] @@ -361,6 +379,26 @@ export class C { ); } +#[test] +fn linker_reads_host_directive_names_like_angular() { + // ngtsc 22.2.4 partial output of + // `hostDirectives: [{ directive: H, inputs: ['é: a'], outputs: ['ö: b'] }]`. + let partial = r#"import * as i0 from "@angular/core"; +export class X { + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.4", type: X, isStandalone: true, selector: "c", hostDirectives: [{ directive: H, inputs: ["\u00E9", "a"], outputs: ["\u00F6", "b"] }], ngImport: i0, template: '', isInline: true }); +}"#; + let code = link(partial, None); + // Angular linker 22.2.4 (Babel 8): the names are read with `getString()` + // and printed as new literals, `inputs: ["é", "a"], outputs: ["ö", "b"]`. + assert_has( + &code, + &[ + r#"i0.ɵɵHostDirectivesFeature([{ directive: H, inputs: ["é", "a"], outputs: ["ö", "b"] }])"#, + ], + ); + assert_lacks(&code, &[r"\u00"]); +} + // ---- linker changeDetection ------------------------------------------------- fn declare_component(version: &str, change_detection: &str) -> String { From 56e09c7cbf98e7ecd5f8d4f586c10e7f23c5ef8f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:05:14 +0800 Subject: [PATCH 16/22] fix(linker): read quoted hostDirectives keys like Angular's linker (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial hostDirectives entry written with quoted keys ({ "directive": …, "inputs": [...] }) was copied as written, so a forwardRef entry was not unwrapped. Each entry is now read like Babel's parseObjectLiteral: a plain or quoted key, and the last one wins when a key is written twice. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 24 +++++++++++++++++-- .../oxc_angular_compiler/tests/linker_test.rs | 6 +++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 18a2c5984..ba7539ff5 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -2314,14 +2314,14 @@ fn host_directives_feature_arg(expr: &Expression<'_>, source: &str) -> Option, source: &str) -> Option( + object: &'a ObjectExpression<'a>, + name: &str, +) -> Option<&'a Expression<'a>> { + object.properties.iter().rev().find_map(|prop| match prop { + ObjectPropertyKind::ObjectProperty(prop) => { + let key = match &prop.key { + PropertyKey::StaticIdentifier(ident) => ident.name.as_str(), + PropertyKey::StringLiteral(s) => s.value.as_str()?, + _ => return None, + }; + (key == name).then_some(&prop.value) + } + ObjectPropertyKind::SpreadProperty(_) => None, + }) +} + /// A partial host directive `inputs` / `outputs` array read like Angular's /// `getHostDirectiveBindingMapping`: pairs collected into an object, so a /// trailing odd element is dropped, a field listed twice keeps its first diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index ec37efc79..3545ed7c3 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1612,6 +1612,12 @@ fn test_link_host_directives_like_angular_linker() { r#"[{ directive: L, inputs: ["color", "a", "color", "b", "1", "one", "x"], outputs: ["changed"] }]"#, Some(r#"[{ directive: L, inputs: ["1", "one", "color", "b"] }]"#), ), + // Quoted keys are read like plain names. + (r#"[{ "directive": i0.forwardRef(() => L) }]"#, Some("function () { return [L]; }")), + ( + r#"[{ "directive": L, "inputs": ["a", "b"] }]"#, + Some(r#"[{ directive: L, inputs: ["a", "b"] }]"#), + ), ]; for (host_directives, expected) in cases { let code = format!( From 2b25351797afe349fa69841b3b5485c1a6295fc3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:19:44 +0800 Subject: [PATCH 17/22] fix(vite): keep the #705 watch stamp in a private temp dir - The stamp file now lives in a new mkdtemp dir (mode 0700), made once per plugin instance. Its path cannot be guessed, so no other user can plant a link there or make the dir read-only. If it cannot be made, warn once. - A server build that is still running takes the new client page in renderChunk, so the client no longer writes the stamp for it. A shared round with a slow server now builds the server once, not twice. - The dir is removed on a normal process exit. Vite sets no signal handler for `vite build --watch`, so a watch stopped with Ctrl+C leaves the empty dir to the system temp cleanup (no signal handler is added on purpose). - Tests: the "cannot make the watched file" test made its project root after pointing TMPDIR at a missing dir, so the test itself threw ENOENT. It now makes the root first. Every watched stamp dir is removed after the suite, so the tests leave nothing in the temp dir. Co-Authored-By: Claude Opus 5.5 --- .../test/ssr-manifest.test.ts | 68 +++++++++++-- .../angular-ssr-manifest-plugin.ts | 97 ++++++++++++++----- 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index 493109799..d48605b28 100644 --- a/napi/angular-compiler/test/ssr-manifest.test.ts +++ b/napi/angular-compiler/test/ssr-manifest.test.ts @@ -1,6 +1,6 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { runInNewContext } from 'node:vm' import { afterAll, describe, it, expect } from 'vitest' @@ -167,7 +167,7 @@ describe('SSR Manifest Generation (Issue #60)', () => { '' const roots: string[] = [] - // The watched files of watch mode, in the temp dir. + // The dirs of the watched files of watch mode, in the temp dir. const stamps: string[] = [] afterAll(() => [...roots, ...stamps].forEach((path) => rmSync(path, { recursive: true, force: true })), @@ -224,7 +224,11 @@ describe('SSR Manifest Generation (Issue #60)', () => { }, meta: { watchMode: watches(name) }, warn: (message: string) => warnings.push(message), - addWatchFile: (file: string) => watched.push(file), + addWatchFile: (file: string) => { + watched.push(file) + // The plugin removes its dir only on a normal process exit. + stamps.push(dirname(file)) + }, }) return { plugin, @@ -235,6 +239,8 @@ describe('SSR Manifest Generation (Issue #60)', () => { clientBundle: (bundle: Record, name = 'client') => plugin.generateBundle.handler.call(context(name), {}, bundle), clientEnd: (error?: Error) => plugin.buildEnd.call(context('client'), error), + serverEnd: (error?: Error) => plugin.buildEnd.call(context('ssr'), error), + serverBundle: () => plugin.generateBundle.handler.call(context('ssr'), {}, {}), clientRenderError: () => plugin.renderError.call(context('client'), new Error('client')), /** The server's buildStart, then a transform of each module. */ async server(...modules: string[]): Promise<(string | undefined)[]> { @@ -493,9 +499,10 @@ describe('SSR Manifest Generation (Issue #60)', () => { const [code] = await build.server(ENGINE) expect(build.watched).toHaveLength(1) const [stamp] = build.watched - stamps.push(stamp) - expect(stamp.endsWith('.stamp')).toBe(true) expect(readFileSync(stamp, 'utf-8')).toBe('') + // A new private dir, so no other user can plant a link there or make it read-only. + expect(basename(dirname(stamp))).toMatch(/^oxc-angular-ssr-.{6}$/) + expect(statSync(dirname(stamp)).mode & 0o777).toBe(0o700) const rendered = build.render(code!) build.clientEnd(new Error('client')) expect(await rendered).toContain('/src/main.ts') @@ -520,6 +527,55 @@ describe('SSR Manifest Generation (Issue #60)', () => { expect(once.watched).toEqual([]) }) + it('does not rebuild a server whose build takes the new page by itself', async () => { + const build = watchApp() + await build.clientStart() + const [code] = await build.server(ENGINE) + const [stamp] = build.watched + const rendered = build.render(code!) + await build.clientBundle(page(BUILT)) + expect(await rendered).toContain(filled(BUILT)) + await build.serverBundle() + + // A shared change, with a server slower than the client: the client + // round ends while the server is still before its renderChunk. + await build.clientStart() + const [slow] = await build.server(ENGINE) + await build.clientBundle(page(NEXT)) + expect(readFileSync(stamp, 'utf-8')).toBe('') + expect(await build.render(slow!)).toContain(filled(NEXT)) + await build.serverBundle() + + // A server build that fails takes no page, so the next client change rebuilds it. + await build.server(ENGINE) + build.serverEnd(new Error('server')) + await build.clientStart() + await build.clientBundle(page(BUILT)) + expect(readFileSync(stamp, 'utf-8')).not.toBe('') + expect(build.warnings).toEqual([]) + }) + + it('warns once when it cannot make the watched file', async () => { + const root = tempRoot() + const missing = join(root, 'missing') + const saved = process.env.TMPDIR + // Only the plugin's temp dir is missing: the root is made before. + process.env.TMPDIR = missing + try { + const build = watchApp(root) + await build.server(ENGINE) + await build.server(ENGINE) + expect(build.watched).toEqual([]) + expect(build.warnings).toHaveLength(1) + expect(build.warnings[0]).toContain( + `could not make a file in ${missing}, so client-only changes do not rebuild the server: `, + ) + } finally { + if (saved === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = saved + } + }) + it('takes the page only from the environment named client', async () => { const build = watchApp() await build.clientStart() diff --git a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts index ce974cdf5..24fe9bf10 100644 --- a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts @@ -14,9 +14,8 @@ * @see https://github.com/voidzero-dev/oxc-angular-compiler/issues/60 */ -import { createHash } from 'node:crypto' -import { realpathSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, relative, resolve } from 'node:path' @@ -136,10 +135,41 @@ interface ServerState { page?: string /** Its renderChunk calls that wait for a client round. */ waiting: number + /** + * It is between buildStart and the point where renderChunk takes the page. + * Such a build takes the newest client page by itself, so it needs no rebuild. + */ + building: boolean /** It gave the warning of this round. */ warned: boolean } +// The dirs of the files below, removed when the process exits normally. Vite +// sets no signal handler for `vite build --watch`, so a watch stopped with +// Ctrl+C leaves its empty dir to the system's temp cleanup. A signal handler +// here would change how the process exits, so there is none. +const stampDirs: string[] = [] + +/** + * Makes the file a watched server watches. It lives in a new private dir + * (mode 0700), so no other user can plant a link there or make it read-only. + * The dir is in the temp dir, by its real path: rolldown does not watch files + * in node_modules (Vite's cacheDir), and on macOS it missed changes to a file + * under the /var symlink of the temp dir. + */ +function createStampFile(): string { + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'oxc-angular-ssr-')) + if (stampDirs.length === 0) { + process.once('exit', () => + stampDirs.forEach((path) => rmSync(path, { recursive: true, force: true })), + ) + } + stampDirs.push(dir) + const path = join(dir, 'client.stamp') + writeFileSync(path, '', { flag: 'wx' }) + return path +} + function newClientRound(): ClientRound { let resolve!: () => void const promise = new Promise((r) => (resolve = r)) @@ -258,11 +288,13 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { // A file each watched server environment watches. Rolldown has no call that // starts a rebuild, so the client writes this file when a server needs its page. let stampPath: string | undefined + // Making the file failed once, so do not try (and warn) again. + let stampFailed = false const servers = new Map() const serverState = (name: string): ServerState => { let state = servers.get(name) - if (!state) servers.set(name, (state = { waiting: 0, warned: false })) + if (!state) servers.set(name, (state = { waiting: 0, building: false, warned: false })) return state } @@ -322,19 +354,30 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { // 'post': vite:build-html adds index.html to the bundle in its own generateBundle. order: 'post', async handler(_, bundle) { - if (this.environment?.config.consumer !== 'client') return + const environment = this.environment + if (environment?.config.consumer === 'server') { + // A build with no manifest to fill in has no renderChunk that takes the page. + const server = servers.get(environment.name) + if (server) server.building = false + return + } + if (environment?.config.consumer !== 'client') return const asset = bundle['index.html'] if (asset?.type === 'asset') clientIndexHtml = asset - if (!isAppClient(this.environment)) return + if (!isAppClient(environment)) return if (asset?.type === 'asset') clientPage = sourceText(asset.source) - // A watched server that embedded another page, and does not wait for - // this one, must rebuild. + // A watched server that embedded another page, and is not building + // now (a build in progress takes this page in renderChunk), must rebuild. const page = clientPage const rebuild = page !== undefined && [...servers.values()].some( - (server) => server.page !== undefined && server.waiting === 0 && server.page !== page, + (server) => + server.page !== undefined && + !server.building && + server.waiting === 0 && + server.page !== page, ) endClientRound(false) if (rebuild && stampPath) { @@ -350,11 +393,18 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { }, buildEnd(error) { - if (error && this.environment && isAppClient(this.environment)) endClientRound(true) + if (!error || !this.environment) return + if (isAppClient(this.environment)) endClientRound(true) + // A failed server build takes no page, so a client change must rebuild it. + const server = servers.get(this.environment.name) + if (server) server.building = false }, renderError() { - if (this.environment && isAppClient(this.environment)) endClientRound(true) + if (!this.environment) return + if (isAppClient(this.environment)) endClientRound(true) + const server = servers.get(this.environment.name) + if (server) server.building = false }, closeBundle() { @@ -386,21 +436,17 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { if (isAppWatchServer(this.environment, this.meta)) { // The page comes in renderChunk. Watch a file that exists before this // build ends (rolldown never watches a path that was missing then), - // so a client-only change can rebuild the server. It lives in the - // temp dir, by its real path: rolldown does not watch files in - // node_modules (Vite's cacheDir), and on macOS it missed changes to a - // file under the /var symlink of the temp dir. - serverState(name).warned = false - const clientOutDir = resolve(config.root, config.environments.client.build?.outDir ?? '') - const hash = createHash('sha256').update(clientOutDir).digest('hex').slice(0, 16) - try { - stampPath = join(realpathSync(tmpdir()), 'oxc-angular-ssr', `${hash}.stamp`) - await mkdir(dirname(stampPath), { recursive: true }) - await writeFile(stampPath, '', { flag: 'wx' }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + // so a client-only change can rebuild the server. + const server = serverState(name) + server.warned = false + server.building = true + if (!stampPath && !stampFailed) { + try { + stampPath = createStampFile() + } catch (error) { + stampFailed = true this.warn( - `could not write ${stampPath ?? tmpdir()}, so client-only changes do not rebuild the server: ${String(error)}`, + `could not make a file in ${tmpdir()}, so client-only changes do not rebuild the server: ${String(error)}`, ) } } @@ -503,6 +549,7 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { server.waiting-- } + server.building = false let page = clientPage let warning: string | undefined if (page === undefined) { From 3f145db3641b666c831c9124cd6b1c36caa6788a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 22:56:06 +0800 Subject: [PATCH 18/22] fix(vite): fail on a missing declarationDir, run the #705 tests on Windows - augmentDeclarations with sources throws when declarationDir is not a directory or holds no declaration files, so a wrong path no longer passes as nothing to do (#706). - The #706 comment and README say partial mode writes never for ngContentSelectors, where ngc lists them. - ssr-manifest tests: skip the 0700 mode check on Windows, which has no mode bits, and point TEMP and TMP at the missing dir too, as os.tmpdir() reads them on Windows (#705). Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/README.md | 6 ++++-- napi/angular-compiler/test/dts-augment.test.ts | 15 +++++++++++++++ napi/angular-compiler/test/ssr-manifest.test.ts | 15 ++++++++++----- .../angular-compiler/vite-plugin/dts-augment.ts | 17 +++++++++++++---- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 79f460cf0..9778faea0 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -376,7 +376,8 @@ kept), but members from an earlier run are then kept as they are. Without TypeScript, pass the output dir and the library's source files instead. Each declaration file is then matched to its source as in the Vite build: by its declaration map, else by the one layout of the dir. A file that -matches no source is left alone and listed in `unmatched`. Pass `tsconfig` +matches no source is left alone and listed in `unmatched`. A `declarationDir` +that does not exist, or holds no declaration files, is an error. Pass `tsconfig` too when decorator values come through `paths` aliases: ```js @@ -398,7 +399,8 @@ dir. `outFile` builds are not supported. Members are the same text the Vite build writes (the same compiler, in partial mode). Templates are read as they are, without the plugin's -`templateTransform`. +`templateTransform`. Like the Vite build, a component's `ngContentSelectors` +is `never`, where ngc lists the selectors. ## Vite Plugin Architecture diff --git a/napi/angular-compiler/test/dts-augment.test.ts b/napi/angular-compiler/test/dts-augment.test.ts index c78e3d616..16718a5fe 100644 --- a/napi/angular-compiler/test/dts-augment.test.ts +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -569,4 +569,19 @@ describe('augmentDeclarations with sources', () => { await expect(augmentDeclarations({ sources })).rejects.toThrow(/declarationDir/) await expect(augmentDeclarations({})).rejects.toThrow(/tsconfig/) }) + + it('fails on a declarationDir that is missing or holds no declarations', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dts-augment-dir-')) + try { + await expect( + augmentDeclarations({ declarationDir: 'dist', sources, cwd: dir }), + ).rejects.toThrow(/is not a directory/) + mkdirSync(join(dir, 'dist')) + await expect( + augmentDeclarations({ declarationDir: 'dist', sources, cwd: dir }), + ).rejects.toThrow(/holds no declaration files/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index d48605b28..b5f51e376 100644 --- a/napi/angular-compiler/test/ssr-manifest.test.ts +++ b/napi/angular-compiler/test/ssr-manifest.test.ts @@ -502,7 +502,8 @@ describe('SSR Manifest Generation (Issue #60)', () => { expect(readFileSync(stamp, 'utf-8')).toBe('') // A new private dir, so no other user can plant a link there or make it read-only. expect(basename(dirname(stamp))).toMatch(/^oxc-angular-ssr-.{6}$/) - expect(statSync(dirname(stamp)).mode & 0o777).toBe(0o700) + // Windows has no mode bits: a new dir reads as 0o777 there. + if (process.platform !== 'win32') expect(statSync(dirname(stamp)).mode & 0o777).toBe(0o700) const rendered = build.render(code!) build.clientEnd(new Error('client')) expect(await rendered).toContain('/src/main.ts') @@ -558,9 +559,11 @@ describe('SSR Manifest Generation (Issue #60)', () => { it('warns once when it cannot make the watched file', async () => { const root = tempRoot() const missing = join(root, 'missing') - const saved = process.env.TMPDIR + // os.tmpdir() reads TMPDIR on POSIX, TEMP or TMP on Windows. + const names = ['TMPDIR', 'TEMP', 'TMP'] as const + const saved = names.map((name) => process.env[name]) // Only the plugin's temp dir is missing: the root is made before. - process.env.TMPDIR = missing + for (const name of names) process.env[name] = missing try { const build = watchApp(root) await build.server(ENGINE) @@ -571,8 +574,10 @@ describe('SSR Manifest Generation (Issue #60)', () => { `could not make a file in ${missing}, so client-only changes do not rebuild the server: `, ) } finally { - if (saved === undefined) delete process.env.TMPDIR - else process.env.TMPDIR = saved + for (const [i, name] of names.entries()) { + if (saved[i] === undefined) delete process.env[name] + else process.env[name] = saved[i] + } } }) diff --git a/napi/angular-compiler/vite-plugin/dts-augment.ts b/napi/angular-compiler/vite-plugin/dts-augment.ts index e18abd27a..3121f2de8 100644 --- a/napi/angular-compiler/vite-plugin/dts-augment.ts +++ b/napi/angular-compiler/vite-plugin/dts-augment.ts @@ -12,7 +12,7 @@ * meant for plain Node scripts. */ import { readFileSync } from 'node:fs' -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, resolve } from 'node:path' @@ -322,8 +322,16 @@ async function matchedTargets( caseSensitive: boolean, result: AugmentDeclarationsResult, ): Promise { + const found = (await stat(dir).catch(() => undefined))?.isDirectory() + ? await findDtsFiles(dir) + : undefined + // A wrong or not yet built dir must not pass as "nothing to do". + if (!found) throw new Error(`augmentDeclarations: declarationDir ${dir} is not a directory.`) + if (found.length === 0 && sources.length > 0) { + throw new Error(`augmentDeclarations: declarationDir ${dir} holds no declaration files.`) + } const files: Array<{ fileName: string; mapSource?: string }> = [] - for (const file of await findDtsFiles(dir)) { + for (const file of found) { const code = await readFile(file, 'utf8') const mapSource = dtsMapSource(code, file, (ref) => readTextSync(resolve(dirname(file), ref))) files.push({ fileName: file, mapSource }) @@ -457,8 +465,9 @@ async function compile( tsconfigPath: context.tsconfigPath, } - // Templates can change the members (`ngContentSelectors`), so they are - // read as they are. No member uses style text, so styles stay empty. + // Templates are read as they are, so they parse and their `` + // stylesheets are found. No member uses style text, so styles stay empty. + // Partial mode writes `never` for `ngContentSelectors`, where ngc lists them. const { templateUrls, styleUrls } = await extractComponentUrls(code, source, options) const templates: Record = {} for (const url of templateUrls) { From 196bb164b2a15ddda1c2f0dc5f7ff806d52010ea Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:07:38 +0800 Subject: [PATCH 19/22] fix(ng-module): read string export names off the namespace in module lists A module list resolved across files (#703) printed an export named by a string as `i1.h-dir`. The scope, the partial lists and the `.d.ts` now write `i1["h-dir"]`, the way hostDirectives already do. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 6 ++--- .../src/ng_module/module_with_providers.rs | 22 +++++----------- .../tests/ng_module_list_reading_test.rs | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index e6191eb23..c9a58f241 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -986,15 +986,15 @@ fn host_directive_namespace_member<'e, 'a>( Some((info, export)) } -/// `i1.name` for the export `name` of `specifier` in the `.d.ts`. +/// `i1.name` for the export `name` of `specifier` in the `.d.ts`, or +/// `i1["h-dir"]` for an export named by a string. fn dts_module_ref<'a>( allocator: &'a Allocator, specifier: &str, name: &str, registry: &mut NamespaceRegistry<'a>, ) -> String { - let namespace = registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))); - format!("{namespace}.{name}") + dts_export_ref(&Ident::from(allocator.alloc_str(specifier)), name, registry) } /// Resolves namespace imports for host directive references in `R3HostDirectiveMetadata`. diff --git a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs index ca2253030..f3fca5491 100644 --- a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs +++ b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs @@ -537,7 +537,7 @@ impl ModuleLists { key: ListKey, mut namespace_of: impl FnMut(&str) -> oxc_str::Ident<'a>, ) -> Option>> { - use crate::output::ast::{OutputExpression, ReadPropExpr, ReadVarExpr}; + use crate::output::ast::{OutputAstBuilder, OutputExpression, ReadVarExpr}; use oxc_allocator::Box; let entries = &self.lists[key as usize]; @@ -560,20 +560,12 @@ impl ModuleLists { for class in refs { list.push(match class { Ref::Local(local) => read_var(name(local)), - Ref::Module { specifier, name: export } => { - OutputExpression::ReadProp(Box::new_in( - ReadPropExpr { - receiver: Box::new_in( - read_var(namespace_of(specifier)), - &allocator, - ), - name: name(export), - optional: false, - source_span: None, - }, - &allocator, - )) - } + // `i1.X`, or `i1["h-dir"]` for an export named by a string. + Ref::Module { specifier, name: export } => OutputAstBuilder::export_read( + allocator, + read_var(namespace_of(specifier)), + name(export), + ), }); } } diff --git a/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs index 102fee28d..259c73dd1 100644 --- a/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs +++ b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs @@ -32,6 +32,16 @@ import { DepModule } from './dep'; export const LIST2 = [DepModule, Dep2Module]; "; +// `H` is exported only under a string name. ngtsc 22.2.4 writes +// `i1.h-dir` for it, which does not parse; Oxc writes `i1["h-dir"]`, the +// same read in valid code (like the hostDirectives of #702). +const DEP3: &str = r#" +import { Directive } from '@angular/core'; +@Directive({ selector: '[h]', standalone: false }) class H {} +export { H as "h-dir" }; +export const DECLS = [H]; +"#; + const FAKELIB: &str = r" import * as i0 from '@angular/core'; import { ModuleWithProviders } from '@angular/core'; @@ -62,6 +72,7 @@ fn project() -> TempDir { let dir = TempDir::new().unwrap(); write(&dir, "src/dep.ts", DEP); write(&dir, "src/dep2.ts", DEP2); + write(&dir, "src/dep3.ts", DEP3); write( &dir, "node_modules/fakelib/package.json", @@ -454,3 +465,18 @@ import * as lib from 'fakelib'; "never,[typeofi1.LibModule,typeofi1.LibModule,typeofi1.LibModule],[typeofi1.LibModule]", ); } + +#[test] +fn a_string_export_name_is_read_off_the_namespace() { + let source = format!( + "{H}import {{ DECLS }} from './dep3'; +@NgModule({{ declarations: DECLS }}) export class M {{}}" + ); + check( + &source, + "import*asi1from'./dep3';", + r#"{declarations:[i1["h-dir"]]}"#, + r#"declarations:[i1["h-dir"]]"#, + r#"[typeofi1["h-dir"]],never,never"#, + ); +} From c8a3431dc5126e458423ce61b83bb00d114474b5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:15:38 +0800 Subject: [PATCH 20/22] test(vite): run the #706 tsc builds from the long temp path on Windows The Windows runner's temp dir is a short name (`RUNNER~1`); take the `native` real path so tsc sees one spelling of each path, and show tsc's output when a build fails. Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/test/dts-augment.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/test/dts-augment.test.ts b/napi/angular-compiler/test/dts-augment.test.ts index 16718a5fe..8f156e054 100644 --- a/napi/angular-compiler/test/dts-augment.test.ts +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -190,7 +190,12 @@ function project(name: string): string { } function tsc(dir: string, ...args: string[]): void { - execFileSync(process.execPath, [tscBin, ...args], { cwd: dir, stdio: 'pipe' }) + try { + execFileSync(process.execPath, [tscBin, ...args], { cwd: dir, stdio: 'pipe', encoding: 'utf8' }) + } catch (error) { + const { stdout = '', stderr = '' } = error as { stdout?: string; stderr?: string } + throw new Error(`tsc ${args.join(' ')} failed in ${dir}:\n${stdout}${stderr}`) + } } function read(file: string): string { @@ -218,7 +223,9 @@ function mappings(file: string): string { } beforeAll(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'oxc-angular-dts-augment-'))) + // `native` also expands a Windows short name (`C:\Users\RUNNER~1`), so + // tsc sees one spelling of every path. + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'oxc-angular-dts-augment-'))) // `typescript` and `@angular/core` for the projects, as a library has them. symlinkSync(join(packageDir, 'node_modules'), join(root, 'node_modules'), 'junction') }) From 9dac19e35705fb6fb337949e710675ef8e01a93a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:24:27 +0800 Subject: [PATCH 21/22] test(vite): build the #706 test projects inside the package On Windows, tsc in the temp dir did not find `@angular/core` through the `node_modules` junction (TS2307). The projects now live under `test/.dts-augment-tmp`, so they find the package's `node_modules` the usual way. Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/.gitignore | 1 + napi/angular-compiler/test/dts-augment.test.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/napi/angular-compiler/.gitignore b/napi/angular-compiler/.gitignore index ac27bae0f..207ddb06e 100644 --- a/napi/angular-compiler/.gitignore +++ b/napi/angular-compiler/.gitignore @@ -11,3 +11,4 @@ /e2e/app/dist /tsconfig.tsbuildinfo /test/.hmr-update-module-tmp +/test/.dts-augment-tmp diff --git a/napi/angular-compiler/test/dts-augment.test.ts b/napi/angular-compiler/test/dts-augment.test.ts index 8f156e054..34f408a46 100644 --- a/napi/angular-compiler/test/dts-augment.test.ts +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -223,11 +223,12 @@ function mappings(file: string): string { } beforeAll(() => { - // `native` also expands a Windows short name (`C:\Users\RUNNER~1`), so - // tsc sees one spelling of every path. - root = realpathSync.native(mkdtempSync(join(tmpdir(), 'oxc-angular-dts-augment-'))) - // `typescript` and `@angular/core` for the projects, as a library has them. - symlinkSync(join(packageDir, 'node_modules'), join(root, 'node_modules'), 'junction') + // Inside the package, so the projects find `typescript` and + // `@angular/core` in its `node_modules` like a library does. (Through a + // junction from the temp dir, tsc on Windows did not find them.) + const parent = join(packageDir, 'test/.dts-augment-tmp') + mkdirSync(parent, { recursive: true }) + root = realpathSync.native(mkdtempSync(join(parent, 'run-'))) }) afterAll(() => { From 9e0a5d9116da04f71296209834af563a68507acf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 10 Nov 2026 23:33:07 +0800 Subject: [PATCH 22/22] fix(linker): keep quotes on keys that are not identifiers The linker rebuilds `outputs` since #707 and only quoted keys with `.` or `-`, so `"some event": "changed"` came out as `some event: "changed"`, which does not parse. Quote any key that isn't an identifier name. Co-Authored-By: Claude Opus 5.5 --- crates/oxc_angular_compiler/src/linker/mod.rs | 9 +++++++-- .../oxc_angular_compiler/tests/linker_test.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index ba7539ff5..282a708c9 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -73,9 +73,14 @@ fn needs_object_key_quoting(key: &str) -> bool { key.contains('.') || key.contains('-') } -/// Quote a property key if it contains unsafe characters (dots or hyphens). +/// Quote a property key if it contains unsafe characters (dots or hyphens), +/// or isn't an identifier at all (`"some event"`). fn quote_key(key: &str) -> String { - if needs_object_key_quoting(key) { escape_string_for_linker(key) } else { key.to_string() } + if needs_object_key_quoting(key) || !oxc_syntax::identifier::is_identifier_name(key) { + escape_string_for_linker(key) + } else { + key.to_string() + } } /// Partial declaration function names to link. diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 3545ed7c3..bab0de545 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1643,3 +1643,22 @@ export class L {{}} assert!(!linked.contains("hostDirectives"), "{linked}"); } } + +/// A key that isn't an identifier stays quoted, so the linked code parses. +#[test] +fn test_link_keys_that_are_not_identifiers_stay_quoted() { + for (code, expected) in [ + ( + make_directive_source_with_outputs(r#""some event": "changed""#), + r#""some event": "changed""#, + ), + (make_directive_source(r#""some input": "someInput""#), r#""some input": "someInput""#), + (make_directive_source_with_outputs(r#""1": "one""#), r#""1": "one""#), + ] { + let allocator = Allocator::default(); + let linked = link(&allocator, &code, "test.mjs").code; + assert!(linked.contains(expected), "{expected}:\n{linked}"); + let parsed = Parser::new(&allocator, &linked, SourceType::mjs()).parse(); + assert!(parsed.diagnostics.is_empty(), "does not parse:\n{linked}"); + } +}