diff --git a/crates/oxc_angular_compiler/src/component/hoist.rs b/crates/oxc_angular_compiler/src/component/hoist.rs index 1f12add6a..5ec0d7f6e 100644 --- a/crates/oxc_angular_compiler/src/component/hoist.rs +++ b/crates/oxc_angular_compiler/src/component/hoist.rs @@ -41,7 +41,7 @@ use oxc_ast::ast::{ }; use oxc_ast_visit::Visit; use oxc_semantic::Semantic; -use oxc_span::GetSpan; +use oxc_span::{GetSpan, Span}; use oxc_syntax::symbol::SymbolId; use crate::directive::StringConsts; @@ -94,6 +94,8 @@ struct PlanEntry { /// insertion at the same offset pushes earlier text further right — the /// hoisted statements end up immediately above the class, with any /// constant-pool declarations from the compiler in between. +/// +/// A hoisted statement keeps the signal `debugName`s inside it. pub fn collect_hoist_edits<'a>( program: &Program<'a>, source: &str, @@ -649,9 +651,10 @@ pub fn collect_hoist_edits<'a>( for stmt_start in &order { let p = &plan[stmt_start]; - let text = &source[*stmt_start as usize..p.stmt_end as usize]; + let text = + super::signal_debug_name::source_text(source, Span::new(*stmt_start, p.stmt_end)); let bucket = per_target.entry(p.insert_at).or_default(); - bucket.push_str(text); + bucket.push_str(&text); bucket.push('\n'); edits.push(Edit::delete(*stmt_start, p.delete_end).with_priority(HOIST_DELETE_PRIORITY)); } diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 5f2202b8d..213d9919a 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -17,6 +17,7 @@ mod hoist; mod import_elision; mod metadata; mod namespace_registry; +pub(crate) mod signal_debug_name; mod transform; #[cfg(feature = "cross_file_elision")] diff --git a/crates/oxc_angular_compiler/src/component/signal_debug_name.rs b/crates/oxc_angular_compiler/src/component/signal_debug_name.rs new file mode 100644 index 000000000..5fc5ebadb --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/signal_debug_name.rs @@ -0,0 +1,487 @@ +//! ngtsc's implicit signal `debugName` transform +//! (`ngtsc/transform/src/implicit_signal_debug_name_transform.ts`). +//! +//! ngtsc always adds `signalMetadataTransform` to its `before` emit +//! transforms, in full and partial compilation alike, and it runs on every +//! emitted file, with or without an Angular decorator. It names the signal +//! created by a `const x = signal(0)`, a `x = signal(0)` class field or a +//! `this.x = signal(0)` statement after its binding, for dev mode only: +//! +//! ```text +//! signal(0, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "x" }] : /* istanbul ignore next */ [])) +//! input(0, { ...(ngDevMode ? { debugName: "x" } : /* istanbul ignore next */ {}), alias: 'a' }) +//! ``` +//! +//! The arguments are edited in the source text, so the call keeps the rest +//! of its formatting. + +use std::borrow::Cow; +use std::cell::RefCell; + +use oxc_ast::ast::{ + AccessorProperty, Argument, AssignmentOperator, AssignmentTarget, CallExpression, ChainElement, + Comment, Expression, ExpressionStatement, IdentifierReference, ImportDeclarationSpecifier, + ObjectPropertyKind, Program, PropertyDefinition, PropertyKey, PropertyKind, Statement, + VariableDeclarator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashSet; + +use crate::optimizer::Edit; +use crate::output::emitter::escape_string; + +/// ngtsc's `signalFunctions`: each function and the `@angular/*` package it +/// must be imported from. +const SIGNAL_FUNCTIONS: [(&str, &str); 13] = [ + ("signal", "core"), + ("computed", "core"), + ("linkedSignal", "core"), + ("input", "core"), + ("model", "core"), + ("viewChild", "core"), + ("viewChildren", "core"), + ("contentChild", "core"), + ("contentChildren", "core"), + ("effect", "core"), + ("afterRenderEffect", "core"), + ("resource", "core"), + ("httpResource", "common"), +]; + +/// The `debugName` edits for every signal the file creates, in source order. +/// +/// The transform's checks (`isVariableDeclarationCase` and friends) match +/// the callee by its local name only, then `expressionIsUsingAngularImportedSymbol` +/// asks the type checker for that name's declaration. Here the semantic +/// model resolves the callee to a named import, so a shadowing local or a +/// differently named alias (`computed as c`) is skipped like upstream. +/// +/// `jit_signal_inputs` are the `input()` fields ngtsc's JIT +/// `signalInputsTransform` rebuilds first, under a synthesized name: there +/// `node.name.getText()` throws and the field is left as is. +pub fn collect_signal_debug_names( + program: &Program<'_>, + source: &str, + scoping: &Scoping, + jit_signal_inputs: &FxHashSet, +) -> SignalDebugNames { + let imports = signal_function_imports(program); + // Nothing in the file can match: skip the walk. + if imports.is_empty() { + return SignalDebugNames::default(); + } + let mut visitor = SignalDebugNameVisitor { + source, + comments: &program.comments, + scoping, + imports, + jit_signal_inputs, + names: SignalDebugNames::default(), + }; + visitor.visit_program(program); + visitor.names +} + +/// The `debugName` edits of a file, each with the declaration it names. +/// +/// Upstream edits the nodes, so every place that prints a node again (the +/// decorator arguments in `ɵcmp` and `ɵsetClassMetadata`, a JIT +/// `__decorate` call) prints the named call. Here the edits apply to the +/// source text: a slice printed elsewhere takes the edits of the +/// declarations inside it ([`source_text`]), and the file keeps the rest +/// ([`SignalDebugNames::into_file_edits`]). +#[derive(Default)] +pub struct SignalDebugNames { + /// The span of the declaration (variable declarator, `a.b = call()` + /// statement or field) and the edit to its call. + edits: Vec<(Span, Edit)>, +} + +impl SignalDebugNames { + /// The edits of the declarations inside `span`, relative to its start. + fn edits_within(&self, span: Span) -> Vec { + self.edits + .iter() + .filter(|(owner, _)| span.start <= owner.start && owner.end <= span.end) + .map(|(_, edit)| Edit { + start: edit.start - span.start, + end: edit.end - span.start, + ..edit.clone() + }) + .collect() + } + + /// The edits the file itself takes: those in text another edit removes + /// or replaces (a decorator, a hoisted statement) are left out, since + /// that text is printed again from [`source_text`] where it is kept. + pub fn into_file_edits(self, edits: &[Edit]) -> Vec { + self.edits + .into_iter() + .map(|(_, edit)| edit) + .filter(|edit| { + let is_removed = |other: &Edit| { + let removes_text = other.start < other.end; + removes_text && other.start < edit.end && edit.start < other.end + }; + !edits.iter().any(is_removed) + }) + .collect() + } +} + +thread_local! { + /// The `debugName`s of the file being compiled on this thread, keyed by + /// its source text's address and length (see [`FileDebugNames`]). + static FILE_DEBUG_NAMES: RefCell> = + const { RefCell::new(None) }; +} + +/// Tells the compiled file's text from any other text the compiler slices. +fn source_key(source: &str) -> (usize, usize) { + (source.as_ptr() as usize, source.len()) +} + +/// Makes a file's `debugName`s visible to [`source_text`] and +/// [`source_edits`] while the file compiles. Decorator metadata reaches the +/// source text through many layers (`make_raw_source` among them), and only +/// slices of this very text take the edits. +pub struct FileDebugNames { + previous: Option<((usize, usize), SignalDebugNames)>, +} + +impl FileDebugNames { + pub fn enter(source: &str, names: SignalDebugNames) -> Self { + let previous = + FILE_DEBUG_NAMES.with(|file| file.replace(Some((source_key(source), names)))); + Self { previous } + } + + /// Ends the scope and hands back the `debugName`s for the file's own edits. + pub fn finish(self) -> SignalDebugNames { + let current = FILE_DEBUG_NAMES.with(|file| file.borrow_mut().take()); + // Restores the enclosing file's scope, if any. + drop(self); + current.map(|(_, names)| names).unwrap_or_default() + } +} + +impl Drop for FileDebugNames { + fn drop(&mut self) { + let previous = self.previous.take(); + FILE_DEBUG_NAMES.with(|file| *file.borrow_mut() = previous); + } +} + +/// `source[span]` for printing elsewhere, with the `debugName`s of the +/// declarations inside it. +pub fn source_text(source: &str, span: Span) -> Cow<'_, str> { + let text = &source[span.start as usize..span.end as usize]; + let edits = source_edits(source, span); + if edits.is_empty() { + Cow::Borrowed(text) + } else { + Cow::Owned(crate::optimizer::apply_edits(text, edits)) + } +} + +/// The `debugName` edits inside `span` of `source`, relative to its start, +/// for a caller that edits the slice itself. +pub fn source_edits(source: &str, span: Span) -> Vec { + FILE_DEBUG_NAMES.with(|file| match &*file.borrow() { + Some((key, names)) if *key == source_key(source) => names.edits_within(span), + _ => Vec::new(), + }) +} + +/// The bindings `expressionIsUsingAngularImportedSymbol` accepts: a named +/// import (`import { signal } from ...`) whose local name is one of +/// [`SIGNAL_FUNCTIONS`], from that function's package or one of its entry +/// points (`@angular/core/rxjs-interop`, `@angular/common/http`). Upstream +/// looks the package up by the local name, not the imported one. +fn signal_function_imports(program: &Program<'_>) -> FxHashSet { + let mut imports = FxHashSet::default(); + for stmt in &program.body { + let Statement::ImportDeclaration(import) = stmt else { continue }; + let Some(module) = import.source.value.as_str() else { continue }; + let Some(package) = module.strip_prefix("@angular/") else { continue }; + let Some(specifiers) = &import.specifiers else { continue }; + for specifier in specifiers { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + let Some(&(_, owner)) = + SIGNAL_FUNCTIONS.iter().find(|(name, _)| specifier.local.name == *name) + else { + continue; + }; + let from_owner = package + .strip_prefix(owner) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + if from_owner && let Some(symbol_id) = specifier.local.symbol_id.get() { + imports.insert(symbol_id); + } + } + } + imports +} + +/// `signalMetadataTransform`'s visitor: a variable declaration, a +/// `a.b = call()` statement or a property declaration whose initializer +/// calls a signal function by name is transformed (or left alone when the +/// name isn't Angular's import) and its children are never visited. Any +/// other node is walked into. +struct SignalDebugNameVisitor<'s> { + source: &'s str, + comments: &'s [Comment], + scoping: &'s Scoping, + imports: FxHashSet, + jit_signal_inputs: &'s FxHashSet, + names: SignalDebugNames, +} + +impl<'a> Visit<'a> for SignalDebugNameVisitor<'_> { + fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { + // `isVariableDeclarationCase` + let Some(call) = signal_function_call(it.init.as_ref()) else { + walk::walk_variable_declarator(self, it); + return; + }; + // `node.name.getText()` + let name = &self.source[it.id.span().start as usize..it.id.span().end as usize]; + self.transform(it.span, call, name); + } + + fn visit_expression_statement(&mut self, it: &ExpressionStatement<'a>) { + // `isPropertyAssignmentCase`: `.name = call()` as a statement. + // The target drops its parentheses here, but `(this.x) = call()` has + // a parenthesized left side upstream, which doesn't match. + if let Expression::AssignmentExpression(assignment) = &it.expression + && assignment.operator == AssignmentOperator::Assign + && assignment.span.start == assignment.left.span().start + && let Some(call) = signal_function_call(Some(&assignment.right)) + { + // `node.expression.left.name.text` + let name = match &assignment.left { + AssignmentTarget::StaticMemberExpression(member) => { + Some(member.property.name.to_string()) + } + AssignmentTarget::PrivateFieldExpression(field) => { + Some(format!("#{}", field.field.name)) + } + _ => None, + }; + if let Some(name) = name { + self.transform(it.span, call, &name); + return; + } + } + walk::walk_expression_statement(self, it); + } + + fn visit_property_definition(&mut self, it: &PropertyDefinition<'a>) { + // `isPropertyDeclarationCase` + let Some(call) = signal_function_call(it.value.as_ref()) else { + walk::walk_property_definition(self, it); + return; + }; + if self.jit_signal_inputs.contains(&it.span) { + return; + } + let name = property_name_text(self.source, self.comments, &it.key, it.computed); + self.transform(it.span, call, name); + } + + fn visit_accessor_property(&mut self, it: &AccessorProperty<'a>) { + // An `accessor` field is a property declaration upstream too. + let Some(call) = signal_function_call(it.value.as_ref()) else { + walk::walk_accessor_property(self, it); + return; + }; + let name = property_name_text(self.source, self.comments, &it.key, it.computed); + self.transform(it.span, call, name); + } +} + +impl SignalDebugNameVisitor<'_> { + /// `transformVariableDeclaration` / `transformPropertyAssignment` / + /// `transformPropertyDeclaration`: only a call to Angular's own function + /// gets the `debugName`. + fn transform(&mut self, declaration: Span, call: &CallExpression<'_>, debug_name: &str) { + let Some(callee) = signal_function_reference(call) else { return }; + let is_angular_import = callee + .reference_id + .get() + .and_then(|reference_id| self.scoping.get_reference(reference_id).symbol_id()) + .is_some_and(|symbol_id| self.imports.contains(&symbol_id)); + if !is_angular_import { + return; + } + if let Some(edit) = insert_debug_name_into_call_expression(self.source, call, debug_name) { + self.names.edits.push((declaration, edit)); + } + } +} + +/// `node.name.getText()` of a property declaration: the name as written, +/// with the quotes of a string key and the brackets of a computed one (and +/// any comments inside them). +fn property_name_text<'s>( + source: &'s str, + comments: &[Comment], + key: &PropertyKey<'_>, + computed: bool, +) -> &'s str { + let span = key.span(); + let (mut start, mut end) = (span.start as usize, span.end as usize); + if computed { + let bytes = source.as_bytes(); + let open = skip_trivia_backward(source, comments, start); + let close = skip_trivia_forward(source, comments, end); + if open > 0 && bytes[open - 1] == b'[' && bytes.get(close) == Some(&b']') { + start = open - 1; + end = close + 1; + } + } + &source[start..end] +} + +/// The offset before the whitespace and comments that end at `offset`. +fn skip_trivia_backward(source: &str, comments: &[Comment], mut offset: usize) -> usize { + loop { + offset = source[..offset].trim_end().len(); + match comments.binary_search_by_key(&offset, |comment| comment.span.end as usize) { + Ok(index) => offset = comments[index].span.start as usize, + Err(_) => return offset, + } + } +} + +/// The offset after the whitespace and comments that start at `offset`. +fn skip_trivia_forward(source: &str, comments: &[Comment], mut offset: usize) -> usize { + loop { + offset = source.len() - source[offset..].trim_start().len(); + match comments.binary_search_by_key(&offset, |comment| comment.span.start as usize) { + Ok(index) => offset = comments[index].span.end as usize, + Err(_) => return offset, + } + } +} + +/// The initializer when it calls a signal function by name: `isSignalFunction` +/// on the callee, or on the object of a `input.required` style callee. +fn signal_function_call<'b, 'a>( + init: Option<&'b Expression<'a>>, +) -> Option<&'b CallExpression<'a>> { + let call = match init? { + Expression::CallExpression(call) => call, + // `signal?.(0)` is a call expression upstream too. + Expression::ChainExpression(chain) => match &chain.expression { + ChainElement::CallExpression(call) => call, + _ => return None, + }, + _ => return None, + }; + signal_function_reference(call).map(|_| call.as_ref()) +} + +/// The identifier naming the signal function: `signal` in `signal(...)` and +/// `input` in `input.required(...)`. +fn signal_function_reference<'b, 'a>( + call: &'b CallExpression<'a>, +) -> Option<&'b IdentifierReference<'a>> { + let expression = match &call.callee { + Expression::StaticMemberExpression(member) => &member.object, + callee => callee, + }; + let Expression::Identifier(id) = expression else { return None }; + SIGNAL_FUNCTIONS.iter().any(|(name, _)| id.name == *name).then_some(id.as_ref()) +} + +/// `insertDebugNameIntoCallExpression`: the edit that passes `debugName` to +/// the call, or `None` when it is left as is. +/// +/// The options object sits after the initial value, except for a call with +/// no arguments, `input.required` / `model.required`, and the functions that +/// take only an options object (`resource`, `linkedSignal({...})`). An +/// options object literal gets the `debugName` spread as its first property; +/// a missing one is appended as a spread argument. Anything else there, or an +/// options object that sets `debugName` itself, is left alone. +fn insert_debug_name_into_call_expression( + source: &str, + call: &CallExpression<'_>, + debug_name: &str, +) -> Option { + let is_required_input = is_required_input_function(&call.callee); + let has_no_args = call.arguments.is_empty(); + let options_first = + has_no_args || is_signal_with_object_only_definition(source, call) || is_required_input; + let config_position = usize::from(!options_first); + let debug_name_property = format!("debugName: {}", escape_string(debug_name, false)); + + match call.arguments.get(config_position) { + Some(Argument::ObjectExpression(object)) => { + let has_debug_name = object.properties.iter().any(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if property.kind == PropertyKind::Init + && !property.method + && !property.shorthand + && !property.computed + && matches!(&property.key, PropertyKey::StaticIdentifier(key) + if key.name == "debugName")) + }); + if has_debug_name { + return None; + } + let spread = format!( + "...{}", + ng_dev_mode_conditional(&format!("{{ {debug_name_property} }}"), "{}") + ); + Some(if object.properties.is_empty() { + Edit::replace(object.span.start, object.span.end, format!("{{ {spread} }}")) + } else { + Edit::insert(object.span.start + 1, format!(" {spread},")) + }) + } + Some(_) => None, + None => { + let spread_args = if has_no_args && !is_required_input { + format!("[undefined, {{ {debug_name_property} }}]") + } else { + format!("[{{ {debug_name_property} }}]") + }; + let spread = + format!("/* @ts-ignore */ ...{}", ng_dev_mode_conditional(&spread_args, "[]")); + Some(match call.arguments.last() { + Some(last) => Edit::insert(last.span().end, format!(", {spread}")), + // Just inside the closing parenthesis. + None => Edit::insert(call.span.end - 1, spread), + }) + } + } +} + +/// `createNgDevModeConditional` +fn ng_dev_mode_conditional(dev_mode_expression: &str, prod_mode_expression: &str) -> String { + format!( + "(ngDevMode ? {dev_mode_expression} : /* istanbul ignore next */ {prod_mode_expression})" + ) +} + +/// `isRequiredInputFunction`: `input.required` or `model.required`. +fn is_required_input_function(callee: &Expression<'_>) -> bool { + let Expression::StaticMemberExpression(member) = callee else { return false }; + let Expression::Identifier(parent) = &member.object else { return false }; + member.property.name == "required" && (parent.name == "input" || parent.name == "model") +} + +/// `isSignalWithObjectOnlyDefinition`: `resource(...)`, or `linkedSignal` +/// called with an object first, judged by the callee as written. +fn is_signal_with_object_only_definition(source: &str, call: &CallExpression<'_>) -> bool { + let callee_text = &source[call.callee.span().start as usize..call.callee.span().end as usize]; + let is_computation_linked_signal = callee_text == "linkedSignal" + && matches!(call.arguments.first(), Some(Argument::ObjectExpression(_))); + is_computation_linked_signal || callee_text == "resource" +} diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index ea6984a72..8a2dd844f 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -34,6 +34,7 @@ use super::hoist::{collect_hoist_edits, program_has_angular_decorated_class}; use super::import_elision::{ImportElisionAnalyzer, import_elision_edits}; use super::metadata::{AngularVersion, ComponentMetadata, HostMetadata}; use super::namespace_registry::NamespaceRegistry; +use super::signal_debug_name; use crate::ast::expression::{BindingType, ParsedEventType}; use crate::ast::r3::{R3BoundAttribute, R3BoundEvent, SecurityContext}; use crate::class_metadata::{ @@ -1289,10 +1290,9 @@ fn extract_jit_ctor_params( let mut decorators = std::vec::Vec::new(); for decorator in ¶m.decorators { if !crate::directive::is_angular_core_decorator(decorator, Some(consts)) { - let expr = decorator.expression.span(); other_decorators.push(format!( "__param({index}, {})", - &source[expr.start as usize..expr.end as usize] + signal_debug_name::source_text(source, decorator.expression.span()) )); continue; } @@ -1300,8 +1300,11 @@ fn extract_jit_ctor_params( Expression::CallExpression(call) => { let args = match (call.arguments.first(), call.arguments.last()) { (Some(first), Some(last)) => Some( - source[first.span().start as usize..last.span().end as usize] - .to_string(), + signal_debug_name::source_text( + source, + Span::new(first.span().start, last.span().end), + ) + .into_owned(), ), _ => None, }; @@ -1389,7 +1392,10 @@ fn extract_all_jit_member_decorators( } else { let start = call.arguments.first().unwrap().span().start; let end = call.arguments.last().unwrap().span().end; - Some(source[start as usize..end as usize].to_string()) + Some( + signal_debug_name::source_text(source, Span::new(start, end)) + .into_owned(), + ) }; (&call.callee, args) } @@ -1420,9 +1426,10 @@ fn extract_all_jit_member_decorators( } else { // Non-Angular decorator (another module's `@Input` or `@Inject` // too) → goes into __decorate() call - let expr_start = decorator.expression.span().start; - let expr_end = decorator.expression.span().end; - non_angular_texts.push(source[expr_start as usize..expr_end as usize].to_string()); + non_angular_texts.push( + signal_debug_name::source_text(source, decorator.expression.span()) + .into_owned(), + ); } } @@ -1817,6 +1824,66 @@ fn synthesize_signal_api_decorators( } } +/// The `input()` fields of the jit-forced `@Component`/`@Directive` classes, +/// which ngtsc's JIT `signalInputsTransform` +/// (`initializer_api_transforms/input_function.ts`) gives an `@Input` +/// decorator. It rebuilds the field with `member.name`, a string, before the +/// signal `debugName` transform runs, so there `node.name.getText()` throws +/// and the field keeps no `debugName`. `model()` and the query functions +/// keep the name node, and a field with its own `@Input` is left alone. +fn jit_signal_input_fields( + program: &oxc_ast::ast::Program<'_>, + string_consts: &crate::directive::StringConsts<'_>, +) -> rustc_hash::FxHashSet { + use oxc_ast::ast::ClassElement; + + let mut fields = rustc_hash::FxHashSet::default(); + for stmt in &program.body { + let class = match stmt { + Statement::ClassDeclaration(class) => class.as_ref(), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), + _ => continue, + }, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => class.as_ref(), + _ => continue, + }, + _ => continue, + }; + // Upstream's visitor only rewrites a named class. + if class.id.is_none() + || !matches!( + find_jit_forced_decorator(class, string_consts), + Some((AngularDecoratorKind::Component | AngularDecoratorKind::Directive, _)) + ) + { + continue; + } + for element in &class.body.body { + let ClassElement::PropertyDefinition(prop) = element else { continue }; + // The keys `extract_all_jit_member_decorators` lowers. + if !matches!(prop.key, PropertyKey::StaticIdentifier(_) | PropertyKey::StringLiteral(_)) + { + continue; + } + let Some(Expression::CallExpression(call)) = &prop.value else { continue }; + let is_signal_input = matches!( + classify_initializer_api(&call.callee, string_consts), + Some(InitializerApiKind::Input | InitializerApiKind::InputRequired) + ); + let has_input_decorator = prop.decorators.iter().any(|decorator| { + crate::directive::angular_member_decorator(decorator, Some(string_consts)) + == Some("Input") + }); + if is_signal_input && !has_input_decorator { + fields.insert(prop.span); + } + } + } + fields +} + /// Returns `true` when any field of any JIT class has a synthesized decorator /// (signal API lowering) that references the `@angular/core` namespace. Used to /// gate the emission of `import * as i0 from "@angular/core"`. @@ -2016,28 +2083,32 @@ fn build_jit_decorator_text<'a>( let expr_start = decorator.expression.span().start as usize; let expr_end = decorator.expression.span().end as usize; let expr_text = &source[expr_start..expr_end]; + // The signal `debugName`s inside the expression, which it keeps. + let mut edits: std::vec::Vec<(usize, usize, String)> = + signal_debug_name::source_edits(source, decorator.expression.span()) + .into_iter() + .map(|edit| (edit.start as usize, edit.end as usize, edit.replacement)) + .collect(); // For non-Component decorators, just return the expression text as-is if !matches!(decorator_kind, AngularDecoratorKind::Component) { - return expr_text.to_string(); + return apply_jit_decorator_text_edits(expr_text, edits); } // For Component decorators, check for resource properties to replace let Expression::CallExpression(call) = &decorator.expression else { - return expr_text.to_string(); + return apply_jit_decorator_text_edits(expr_text, edits); }; let Some(config_arg) = call.arguments.first() else { - return expr_text.to_string(); + return apply_jit_decorator_text_edits(expr_text, edits); }; let Argument::ObjectExpression(config_obj) = config_arg else { - return expr_text.to_string(); + return apply_jit_decorator_text_edits(expr_text, edits); }; - // Collect edits within the expression text - let mut edits: std::vec::Vec<(usize, usize, String)> = std::vec::Vec::new(); - + // Collect the resource edits within the expression text for prop in &config_obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop { let key_name = match &prop.key { @@ -2119,12 +2190,16 @@ fn build_jit_decorator_text<'a>( } } - if edits.is_empty() { - return expr_text.to_string(); - } + apply_jit_decorator_text_edits(expr_text, edits) +} +/// `text` with `edits` (offsets into it) applied. +fn apply_jit_decorator_text_edits( + text: &str, + mut edits: std::vec::Vec<(usize, usize, String)>, +) -> String { // Apply edits in reverse order to preserve positions - let mut result = expr_text.to_string(); + let mut result = text.to_string(); edits.sort_by(|a, b| b.0.cmp(&a.0)); for (start, end, replacement) in edits { result.replace_range(start..end, &replacement); @@ -2592,10 +2667,8 @@ fn collect_jit_class_info<'a>( all_class_decorator_texts.push(text); } else { // Non-Angular decorator: extract expression text from source (without @) - let expr_start = dec.expression.span().start; - let expr_end = dec.expression.span().end; all_class_decorator_texts - .push(source[expr_start as usize..expr_end as usize].to_string()); + .push(signal_debug_name::source_text(source, dec.expression.span()).into_owned()); } } @@ -3276,6 +3349,24 @@ pub fn transform_angular_file( } } + // ngtsc's implicit signal `debugName` transform: `signal(0)` and the + // other signal APIs get an ngDevMode `debugName` argument named after the + // binding they initialize. ngtsc adds it to the `before` emit transforms + // in full and partial compilation, for every emitted file, whether or not + // it has an Angular decorator. It runs on the emitted nodes, so the + // decorator arguments printed again below (`ɵcmp`, `ɵsetClassMetadata`, + // a JIT `__decorate`) are named too: their slices of the source take the + // edits through this scope. + let debug_names_scope = signal_debug_name::FileDebugNames::enter( + source, + signal_debug_name::collect_signal_debug_names( + &parser_ret.program, + source, + semantic.scoping(), + &jit_signal_input_fields(&parser_ret.program, &string_consts), + ), + ); + // The imports a `jit: true` class's downleveled output keeps live, found // before any class compiles so `@defer` leaves them alone whichever class // comes first (#313). @@ -4783,6 +4874,10 @@ pub fn transform_angular_file( )); } + // 5f. The signal `debugName` edits of the text the file keeps. + let debug_name_edits = debug_names_scope.finish().into_file_edits(&edits); + edits.extend(debug_name_edits); + // Members can reference namespace aliases the registry assigned to // other modules (e.g. `typeof i1.SomeDirective` for host directives). // Record each referenced alias's module so the consumer emits the diff --git a/crates/oxc_angular_compiler/src/output/oxc_converter.rs b/crates/oxc_angular_compiler/src/output/oxc_converter.rs index 5fa5069f6..82fe737ba 100644 --- a/crates/oxc_angular_compiler/src/output/oxc_converter.rs +++ b/crates/oxc_angular_compiler/src/output/oxc_converter.rs @@ -748,7 +748,8 @@ fn convert_oxc_binary_operator(op: oxc_ast::ast::BinaryOperator) -> Option( @@ -757,8 +758,8 @@ pub(crate) fn make_raw_source<'a>( span: Span, ) -> Option> { let source = source_text?; - let raw = &source[span.start as usize..span.end as usize]; - let js = strip_expression_types(raw); + let raw = crate::component::signal_debug_name::source_text(source, span); + let js = strip_expression_types(&raw); Some(OutputExpression::RawSource(Box::new_in( RawSourceExpr { source: Ident::from(allocator.alloc_str(&js)), source_span: None }, &allocator, diff --git a/crates/oxc_angular_compiler/src/service/decorator.rs b/crates/oxc_angular_compiler/src/service/decorator.rs index c36d753fa..cda6de5d4 100644 --- a/crates/oxc_angular_compiler/src/service/decorator.rs +++ b/crates/oxc_angular_compiler/src/service/decorator.rs @@ -12,6 +12,8 @@ //! See `component/transform.rs::find_angular_service_decorator` for the //! import-map gate the AOT caller uses. +use std::borrow::Cow; + use oxc_allocator::{Allocator, Box}; use oxc_ast::ast::{Argument, Class, Decorator, Expression, ObjectPropertyKind, PropertyKey}; use oxc_span::{GetSpan, Span}; @@ -53,7 +55,7 @@ pub fn find_service_decorator_span(class: &Class<'_>) -> Option { /// the class's decorators by literal name — which would skip aliased imports /// such as `import { Service as NgService }; @NgService()`. pub fn extract_service_metadata<'a>( - _allocator: &'a Allocator, + allocator: &'a Allocator, class: &'a Class<'a>, decorator: &'a Decorator<'a>, source_text: Option<&'a str>, @@ -98,8 +100,16 @@ pub fn extract_service_metadata<'a>( } "factory" => { if let Some(src) = source_text { - let span = prop.value.span(); - factory = Some(&src[span.start as usize..span.end as usize]); + // With the signal `debugName`s inside it. + factory = Some( + match crate::component::signal_debug_name::source_text( + src, + prop.value.span(), + ) { + Cow::Borrowed(text) => text, + Cow::Owned(text) => allocator.alloc_str(&text), + }, + ); } } _ => {} diff --git a/crates/oxc_angular_compiler/tests/signal_debug_name_test.rs b/crates/oxc_angular_compiler/tests/signal_debug_name_test.rs new file mode 100644 index 000000000..e7ead6887 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/signal_debug_name_test.rs @@ -0,0 +1,414 @@ +//! ngtsc's implicit signal `debugName` transform +//! (`implicit_signal_debug_name_transform.ts`): signal APIs imported from +//! `@angular/core` (`httpResource` from `@angular/common`) get an ngDevMode +//! `debugName` argument named after the binding they initialize, in every +//! file. Expected texts are ngtsc 22.2.3's output, printed on one line. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +fn transform(source: &str) -> String { + transform_with(source, &TransformOptions::default()) +} + +fn transform_with(source: &str, options: &TransformOptions) -> String { + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "test.ts", source, Some(options), None); + assert!(!result.has_errors(), "unexpected errors: {:?}", result.diagnostics); + result.code +} + +fn spread(name: &str) -> String { + format!( + "/* @ts-ignore */ ...(ngDevMode ? [{{ debugName: \"{name}\" }}] : /* istanbul ignore next */ [])" + ) +} + +fn object_spread(name: &str) -> String { + format!("...(ngDevMode ? {{ debugName: \"{name}\" }} : /* istanbul ignore next */ {{}})") +} + +#[test] +fn component_fields_get_debug_names() { + let code = transform( + r" +import { Component, signal, computed, input, model, linkedSignal, viewChild, viewChildren, contentChild, effect, output, resource } from '@angular/core'; +@Component({ selector: 'app-x', template: '
' }) +export class X { + count = signal(0); + dbl = computed(() => this.count() * 2); + name = input('a'); + req = input.required(); + aliased = input(0, { alias: 'al' }); + val = model(1); + ls = linkedSignal(() => this.count()); + ref = viewChild('ref'); + refs = viewChildren('ref'); + cc = contentChild('cc'); + r = resource({ loader: async () => 1 }); + withOpts = signal(0, { equal: (a, b) => a === b }); + ev = effect(() => {}); + out = output(); +} +", + ); + for expected in [ + format!("count = signal(0, {})", spread("count")), + format!("dbl = computed(() => this.count() * 2, {})", spread("dbl")), + format!("name = input('a', {})", spread("name")), + // `input.required` takes its options first. + format!("req = input.required({})", spread("req")), + format!("aliased = input(0, {{ {}, alias: 'al' }})", object_spread("aliased")), + format!("val = model(1, {})", spread("val")), + format!("ls = linkedSignal(() => this.count(), {})", spread("ls")), + format!("ref = viewChild('ref', {})", spread("ref")), + format!("refs = viewChildren('ref', {})", spread("refs")), + format!("cc = contentChild('cc', {})", spread("cc")), + // `resource` takes only an options object. + format!("r = resource({{ {}, loader: async () => 1 }})", object_spread("r")), + format!( + "withOpts = signal(0, {{ {}, equal: (a, b) => a === b }})", + object_spread("withOpts") + ), + format!("ev = effect(() => {{}}, {})", spread("ev")), + ] { + assert!(code.contains(&expected), "expected `{expected}` in:\n{code}"); + } + // `output` isn't a signal function. + assert!(code.contains("out = output();"), "{code}"); +} + +#[test] +fn plain_file_without_angular_decorator() { + let code = transform( + r" +import { signal, computed as c } from '@angular/core'; +export const top = signal(1); +const al = c(() => 1); +export class Plain { + s = signal(undefined); + t: any; + constructor() { + this.t = signal(2); + } +} +", + ); + assert!(code.contains(&format!("export const top = signal(1, {});", spread("top"))), "{code}"); + // An alias isn't one of the names the transform looks for. + assert!(code.contains("const al = c(() => 1);"), "{code}"); + assert!( + code.contains(&format!("s = signal(undefined, {});", spread("s"))), + "{code}" + ); + assert!(code.contains(&format!("this.t = signal(2, {});", spread("t"))), "{code}"); +} + +#[test] +fn argument_position_and_existing_options() { + let code = transform( + r" +import { Directive, signal, computed, input, model, linkedSignal, effect } from '@angular/core'; +import { httpResource } from '@angular/common/http'; +export const a = signal(0, { equal: undefined, debugName: 'mine' }); +export const b = signal(0, { 'debugName': 'q' }); +const opts = {}; +export const d = signal(0, opts); +export const g = linkedSignal({ source: a, computation: () => 1 }); +export const h = linkedSignal(() => 1, { equal: Object.is }); +export const j = httpResource(() => '/x'); +export const k = effect(() => {}, {}); +export const w = signal(0,); +@Directive({ selector: '[d]' }) +export class D { + e = input.required({ alias: 'xx' }); + f = model.required(); + x = input(); +} +", + ); + for expected in [ + // An options object that sets `debugName` itself is left alone. + "export const a = signal(0, { equal: undefined, debugName: 'mine' });".to_string(), + // ...but only an identifier key counts. + format!("export const b = signal(0, {{ {}, 'debugName': 'q' }});", object_spread("b")), + // Options that aren't an object literal are left alone. + "export const d = signal(0, opts);".to_string(), + format!( + "export const g = linkedSignal({{ {}, source: a, computation: () => 1 }});", + object_spread("g") + ), + format!("export const h = linkedSignal(() => 1, {{ {}, equal: Object.is }});", object_spread("h")), + format!("export const j = httpResource(() => '/x', {});", spread("j")), + format!("export const k = effect(() => {{}}, {{ {} }});", object_spread("k")), + format!("export const w = signal(0, {},);", spread("w")), + format!("e = input.required({{ {}, alias: 'xx' }});", object_spread("e")), + format!("f = model.required({});", spread("f")), + // No arguments: the initial value is `undefined`. + "x = input(/* @ts-ignore */ ...(ngDevMode ? [undefined, { debugName: \"x\" }] : /* istanbul ignore next */ []));".to_string(), + ] { + assert!(code.contains(&expected), "expected `{expected}` in:\n{code}"); + } +} + +#[test] +fn names_and_positions() { + let code = transform( + r" +import { signal, computed } from '@angular/core'; +export const l = computed(() => { + const inner = signal(1); + return inner(); +}); +function wrap(fn: any) { return fn(); } +export const m = wrap(() => { const inner2 = signal(2); return inner2; }); +export let o = signal(0), p = computed(() => 1); +export const t = (signal(0)); +export const u = signal(0) as any; +export const bb = signal.bind(null); +function local() { const signal = (x: number) => x; const shadow = signal(1); return shadow; } +export class K { + #priv = signal(0); + 'quoted-name' = signal(1); + static st = signal(2); + accessor acc = signal(3); + ['comp'] = signal(4); + other: any; + constructor() { + this.#priv = signal(6); + (this as any).dyn = computed(() => 7); + (this as any)['el'] = signal(8); + let q = this.other = signal(9); + } +} +for (const ff = signal(0); ;) { break; } +", + ); + for expected in [ + // A transformed initializer's own body isn't visited. + format!(" const inner = signal(1);\n return inner();\n}}, {});", spread("l")), + // Any other initializer is. + format!("const inner2 = signal(2, {});", spread("inner2")), + format!( + "export let o = signal(0, {}), p = computed(() => 1, {});", + spread("o"), + spread("p") + ), + "export const t = (signal(0));".to_string(), + "export const u = signal(0) as any;".to_string(), + format!("export const bb = signal.bind(null, {});", spread("bb")), + // A shadowing local isn't Angular's `signal`. + "const shadow = signal(1);".to_string(), + format!("#priv = signal(0, {});", spread("#priv")), + format!("'quoted-name' = signal(1, {});", spread("'quoted-name'")), + format!("static st = signal(2, {});", spread("st")), + format!("accessor acc = signal(3, {});", spread("acc")), + format!("['comp'] = signal(4, {});", spread("['comp']")), + format!("this.#priv = signal(6, {});", spread("#priv")), + format!("(this as any).dyn = computed(() => 7, {});", spread("dyn")), + "(this as any)['el'] = signal(8);".to_string(), + "let q = this.other = signal(9);".to_string(), + format!("for (const ff = signal(0, {}); ;)", spread("ff")), + ] { + assert!(code.contains(&expected), "expected `{expected}` in:\n{code}"); + } +} + +#[test] +fn only_angular_imports() { + let code = transform( + r" +import { signal } from './my-signals'; +import { computed } from '@angular/core/primitives/signals'; +import { input } from '@angular/corex'; +import * as ng from '@angular/core'; +export const a = signal(0); +export const b = computed(() => 1); +export const c = input(); +export const d = ng.signal(0); +", + ); + assert!(code.contains("export const a = signal(0);"), "{code}"); + // An entry point of `@angular/core` is still Angular's. + assert!( + code.contains(&format!("export const b = computed(() => 1, {});", spread("b"))), + "{code}" + ); + assert!(code.contains("export const c = input();"), "{code}"); + // A namespace import isn't matched by name. + assert!(code.contains("export const d = ng.signal(0);"), "{code}"); +} + +#[test] +fn partial_mode_and_advanced_optimizations_keep_it() { + let source = r" +import { signal } from '@angular/core'; +export const top = signal(1); +"; + let expected = format!("export const top = signal(1, {});", spread("top")); + let partial = TransformOptions { + compilation_mode: oxc_angular_compiler::CompilationMode::Partial, + ..TransformOptions::default() + }; + assert!(transform_with(source, &partial).contains(&expected)); + let optimized = + TransformOptions { advanced_optimizations: true, ..TransformOptions::default() }; + assert!(transform_with(source, &optimized).contains(&expected)); + // The JIT build has no such transform (`@angular/build`'s JIT compilation + // never adds it). + let jit = TransformOptions { jit: true, ..TransformOptions::default() }; + assert!(transform_with(source, &jit).contains("export const top = signal(1);")); +} + +#[test] +fn hoisted_binding_keeps_its_debug_name() { + let code = transform( + r" +import { Component, InjectionToken, signal } from '@angular/core'; +export const TOK = new InjectionToken('t'); +@Component({ selector: 'a-h', template: '', providers: [{ provide: TOK, useValue: state }] }) +export class H {} +const state = signal(0); +", + ); + let expected = format!("const state = signal(0, {});", spread("state")); + assert_eq!(code.matches(&expected).count(), 1, "{code}"); + assert!(code.find(&expected).unwrap() < code.find("class H").unwrap(), "{code}"); +} + +/// The output is valid JavaScript (the edits don't land in removed text). +fn assert_parses(code: &str) { + let allocator = Allocator::default(); + let ret = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::ts()).parse(); + assert!(ret.diagnostics.is_empty(), "{:?} in:\n{code}", ret.diagnostics); +} + +#[test] +fn decorator_arguments_are_named_in_every_copy() { + // ngtsc's definitions and `ɵsetClassMetadata` reuse the decorator's + // argument nodes, and the transform names the calls in both. + let source = r" +import { Component, Directive, Input, NgModule, signal } from '@angular/core'; +@Component({ selector: 'x', template: '', providers: [{ provide: 'k', useFactory: () => { const fromProv = signal(0); return fromProv; } }, { provide: 'c', useValue: new (class { f = signal(0) })() }] }) +export class X {} +@NgModule({ providers: [{ provide: 'm', useFactory: () => { const fromMod = signal(0); return fromMod; } }] }) +export class M {} +@Directive({ selector: '[d]' }) +export class D { @Input({ transform: (v: any) => { const fromTr = signal(v); return fromTr; } }) a: any; } +"; + let partial = TransformOptions { + compilation_mode: oxc_angular_compiler::CompilationMode::Partial, + ..TransformOptions::default() + }; + for options in [TransformOptions::default(), partial] { + let code = transform_with(source, &options); + assert_parses(&code); + for expected in [ + format!("const fromProv = signal(0, {});", spread("fromProv")), + format!("f = signal(0, {})", spread("f")), + format!("const fromMod = signal(0, {});", spread("fromMod")), + // Printed again without its types. + "debugName: \"fromTr\"".to_string(), + ] { + assert_eq!(code.matches(&expected).count(), 2, "`{expected}` in:\n{code}"); + } + } + + // `ɵprov` calls the `useFactory` function. + let code = transform( + r" +import { Injectable, computed } from '@angular/core'; +@Injectable({ providedIn: 'root', useFactory: () => { const fromInj = computed(() => 1); return fromInj; } }) +export class Y {} +", + ); + let expected = format!("const fromInj = computed(() => 1, {});", spread("fromInj")); + assert_eq!(code.matches(&expected).count(), 2, "{code}"); +} + +#[test] +fn jit_forced_class() { + let code = transform( + r" +import { Component, ElementRef, Input, input, model, viewChild, signal } from '@angular/core'; +@Component({ jit: true, selector: 'x', template: '', providers: [{ provide: 'k', useFactory: () => { const fromJit = signal(0); return fromJit; } }] }) +export class X { + name = input('a'); + req = input.required(); + @Input() dec = input(1); + m = model(0); + q = viewChild('q', { read: ElementRef }); + s = signal(0); +} +@Component({ jit: true, selector: 'y', template: '' }) +export default class { + anon = input('a'); +} +", + ); + assert_parses(&code); + // The decorator moves into `__decorate` with its debugName. + let from_jit = format!("const fromJit = signal(0, {});", spread("fromJit")); + assert_eq!(code.matches(&from_jit).count(), 1, "{code}"); + assert!(code.find(&from_jit).unwrap() > code.find("X = __decorate(").unwrap(), "{code}"); + // The JIT `signalInputsTransform` rebuilds `input()` fields under a + // synthesized name first, so they keep no debugName. + assert!(code.contains("name = input('a');"), "{code}"); + assert!(code.contains("req = input.required();"), "{code}"); + for expected in [ + // A field with its own `@Input`, and a field of a nameless class, + // aren't rebuilt. + format!("dec = input(1, {})", spread("dec")), + format!("anon = input('a', {})", spread("anon")), + format!("m = model(0, {})", spread("m")), + format!("q = viewChild('q', {{ {}, read: ElementRef }})", object_spread("q")), + format!("s = signal(0, {})", spread("s")), + ] { + assert!(code.contains(&expected), "expected `{expected}` in:\n{code}"); + } + // The query decorator spreads the options as written. + assert!(code.contains("{ ...{ read: ElementRef }, isSignal: true }"), "{code}"); +} + +#[test] +fn service_factory_is_named() { + let code = transform( + r" +import { Service, signal } from '@angular/core'; +@Service({ factory: () => { const fromSvc = signal(0); return new S(); } }) +export class S {} +", + ); + let expected = format!("const fromSvc = signal(0, {});", spread("fromSvc")); + assert_eq!(code.matches(&expected).count(), 2, "{code}"); +} + +#[test] +fn call_and_target_shapes() { + let code = transform( + r" +import { signal } from '@angular/core'; +export const a = signal?.(0); +export class K { + b: any; + c: any; + constructor() { + (this.b) = signal(1); + (this).c = signal(2); + } +} +const k = 'k'; +const k2 = 'k2'; +export class L { [/* c */ k] = signal(0); [ k2 // x +] = signal(1); } +", + ); + // An optional call is a call expression upstream too. + assert!(code.contains(&format!("export const a = signal?.(0, {});", spread("a"))), "{code}"); + // A parenthesized left side isn't a property access. + assert!(code.contains("(this.b) = signal(1);"), "{code}"); + assert!(code.contains(&format!("(this).c = signal(2, {});", spread("c"))), "{code}"); + // `node.name.getText()` keeps the comments inside the brackets. + assert!(code.contains(&format!("signal(0, {})", spread("[/* c */ k]"))), "{code}"); + assert!(code.contains(&format!("signal(1, {})", spread("[ k2 // x\\n]"))), "{code}"); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts index d99d4b084..69513e6ec 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/known-differences.ts @@ -32,8 +32,6 @@ export interface KnownDifference { reasons: string[] } -const DEBUG_NAME = - 'signal(), input() and the other signal APIs lack the ngDevMode `debugName` argument ngtsc adds to class fields' const DOM_ONLY = 'templates use ɵɵelementStart/ɵɵlistener where a full ngtsc build of a standalone component without directive dependencies emits the DOM-only ɵɵdomElementStart/ɵɵdomListener' const COMPONENT_DEPS = @@ -164,198 +162,7 @@ export const KNOWN_DIFFERENCES: Record = { }, 'regressions/formfield-alias-repro': { fields: ['AppComponent.ɵcmp'], - classes: ['AppComponent'], - reasons: [COMPONENT_DEPS, DEBUG_NAME], - }, - 'templates/arrow-control-flow-expressions': { - fields: [], - classes: ['ArrowControlFlowExpressionsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-i18n': { - fields: [], - classes: ['ArrowHoistedI18nComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-if-alias': { - fields: [], - classes: ['ArrowHoistedIfAliasComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-nested-views': { - fields: [], - classes: ['ArrowHoistedNestedViewsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-ng-template-variables': { - fields: [], - classes: ['ArrowHoistedNgTemplateVariablesComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-order': { - fields: [], - classes: ['ArrowHoistedOrderComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-root-reference': { - fields: [], - classes: ['ArrowHoistedRootReferenceComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-same-arrow-in-two-views': { - fields: [], - classes: ['ArrowHoistedSameArrowInTwoViewsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-hoisted-two-in-one-view': { - fields: [], - classes: ['ArrowHoistedTwoInOneViewComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-interpolation-identical-arrows': { - fields: [], - classes: ['ArrowInterpolationIdenticalArrowsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-interpolation-multiple-params': { - fields: [], - classes: ['ArrowInterpolationMultipleParamsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-interpolation-no-param': { - fields: [], - classes: ['ArrowInterpolationNoParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-interpolation-one-param': { - fields: [], - classes: ['ArrowInterpolationOneParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-interpolation-param-and-property': { - fields: [], - classes: ['ArrowInterpolationParamAndPropertyComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-let-chain': { - fields: [], - classes: ['ArrowLetChainComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-let-in-for': { - fields: [], - classes: ['ArrowLetInForComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-let-reading-let': { - fields: [], - classes: ['ArrowLetReadingLetComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-multiple-params': { - fields: [], - classes: ['ArrowListenerMultipleParamsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-nested': { - fields: [], - classes: ['ArrowListenerNestedComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-no-param': { - fields: [], - classes: ['ArrowListenerNoParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-one-param': { - fields: [], - classes: ['ArrowListenerOneParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-param-and-property': { - fields: [], - classes: ['ArrowListenerParamAndPropertyComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-param-shadows-for-item': { - fields: [], - classes: ['ArrowListenerParamShadowsForItemComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-param-shadows-let': { - fields: [], - classes: ['ArrowListenerParamShadowsLetComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-param-shadows-property': { - fields: [], - classes: ['ArrowListenerParamShadowsPropertyComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-param-shadows-ref': { - fields: [], - classes: ['ArrowListenerParamShadowsRefComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-listener-reads-template-variables': { - fields: [], - classes: ['ArrowListenerReadsTemplateVariablesComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-nested': { - fields: [], - classes: ['ArrowPropertyNestedComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-no-param': { - fields: [], - classes: ['ArrowPropertyNoParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-one-param': { - fields: [], - classes: ['ArrowPropertyOneParamComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-param-and-property': { - fields: [], - classes: ['ArrowPropertyParamAndPropertyComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-param-shadows-for-item': { - fields: [], - classes: ['ArrowPropertyParamShadowsForItemComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-param-shadows-let': { - fields: [], - classes: ['ArrowPropertyParamShadowsLetComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-param-shadows-property': { - fields: [], - classes: ['ArrowPropertyParamShadowsPropertyComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-param-shadows-ref': { - fields: [], - classes: ['ArrowPropertyParamShadowsRefComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-reads-template-variables': { - fields: [], - classes: ['ArrowPropertyReadsTemplateVariablesComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/arrow-property-several-bindings': { - fields: [], - classes: ['ArrowPropertySeveralBindingsComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/case-matches-component': { - fields: [], - classes: ['ViewCmp'], - reasons: [DEBUG_NAME], + reasons: [COMPONENT_DEPS], }, 'templates/dom-only-empty-imports': { fields: ['DomOnlyEmptyImportsComponent.ɵcmp'], @@ -369,24 +176,4 @@ export const KNOWN_DIFFERENCES: Record = { fields: ['TreeComponent.ɵcmp'], reasons: [SELF_DEPENDENCY], }, - 'templates/ng-arrow-function-dollar-event': { - fields: [], - classes: ['NgArrowFunctionDollarEventComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/ng-arrow-function-host-listener': { - fields: [], - classes: ['TestDir'], - reasons: [DEBUG_NAME], - }, - 'templates/ng-arrow-function-nested-listeners': { - fields: [], - classes: ['NgArrowFunctionNestedListenersComponent'], - reasons: [DEBUG_NAME], - }, - 'templates/ng-arrow-function-no-context': { - fields: [], - classes: ['NgArrowFunctionNoContextComponent'], - reasons: [DEBUG_NAME], - }, } diff --git a/napi/angular-compiler/test/plugin-fast-paths.test.ts b/napi/angular-compiler/test/plugin-fast-paths.test.ts index 444f4e8d4..045feeabd 100644 --- a/napi/angular-compiler/test/plugin-fast-paths.test.ts +++ b/napi/angular-compiler/test/plugin-fast-paths.test.ts @@ -5,6 +5,8 @@ * spells `templateUrl`/`styleUrl`, and still compiles a file that names a * resource through an escaped key. Only a missing-resource error sends the * file back through the full resolution; a template syntax error does not. + * - A file without an Angular decorator is compiled only when it calls a + * signal API, which ngtsc gives a `debugName`. * - JIT `transform` never reads external resources: its output imports them. * - The `@ng/component` endpoint compiles with the decorator's * `preserveWhitespaces`, like the full transform. @@ -277,6 +279,68 @@ describe('transform skips the resource URL parse when no URL is spelled', () => }) }) +describe('a file without an Angular decorator', () => { + const store = ` + import { signal } from '@angular/core'; + export const count = signal(0); + ` + + it('is compiled when it calls a signal API, for its debugName', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + + const { code, errors } = await transform(plugin, store, join(appDir, 'store.ts')) + + expect(errors).toEqual([]) + expect(code).toContain('signal(0, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "count" }]') + expect(spies.extractComponentUrls).not.toHaveBeenCalled() + }) + + it('is compiled for an optional or commented call', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + + const { code, errors } = await transform( + plugin, + ` + import { signal } from '@angular/core'; + export const a = signal?.(0); + export const b = signal /* c */ (1); + `, + join(appDir, 'store-shapes.ts'), + ) + + expect(errors).toEqual([]) + expect(code).toContain('debugName: "a"') + expect(code).toContain('debugName: "b"') + }) + + it('is skipped when it calls no signal API', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + + await transform( + plugin, + ` + import { inject } from '@angular/core'; + export const token = () => inject(Object); + `, + join(appDir, 'util.ts'), + ) + + expect(spies.transformAngularFile).not.toHaveBeenCalled() + }) + + it('is skipped in JIT mode, which names no signals', async () => { + const plugin = getAngularPlugin({ jit: true, liveReload: true }) + await setupPlugin(plugin) + + await transform(plugin, store, join(appDir, 'store-jit.ts')) + + expect(spies.transformAngularFile).not.toHaveBeenCalled() + }) +}) + describe('JIT transform', () => { it('does not read or parse external resources', async () => { const plugin = getAngularPlugin({ jit: true, liveReload: true }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index afb5803e4..9deb9cc66 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -173,6 +173,11 @@ export interface PluginOptions { // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler const ANGULAR_TS_REGEX = /\.tsx?$/ +// The name of a signal API ngtsc's implicit signal debugName transform names. Only the +// name is matched: the file has to import it, and a call can take any shape +// (`signal?.(0)`, `signal /* c */ (0)`, `input.required(`, `computed(`, ...). +const SIGNAL_API_NAME_REGEX = + /\b(?:signal|computed|linkedSignal|input|model|viewChild|viewChildren|contentChild|contentChildren|effect|afterRenderEffect|resource|httpResource)\b/ const TEMPLATE_REGEX = /\.html?$/ const ANGULAR_COMPONENT_PREFIX = '@ng/component' @@ -1056,13 +1061,43 @@ export function angular(options: PluginOptions = {}): Plugin[] { code.includes('@Service') || (code.includes('@angular/core') && /\b(?:Component|Directive|NgModule|Injectable|Pipe|Service)\b/.test(code)) - if (!hasAngularDecorator) { + // ngtsc names the signals of every file it emits (its implicit signal + // debugName transform), so a file without a decorator is still + // compiled when it calls a signal API from `@angular/core` (or + // `httpResource` from `@angular/common`). The JIT build has no such + // transform. + const mayNameSignals = + !hasAngularDecorator && + !pluginOptions.jit && + (code.includes('@angular/core') || code.includes('@angular/common')) && + SIGNAL_API_NAME_REGEX.test(code) + if (!hasAngularDecorator && !mayNameSignals) { return } // Apply file replacements const actualId = pluginOptions.fileReplacements?.[id] ?? id + // Such a file has no component: nothing to resolve or register for HMR. + if (mayNameSignals) { + const result = await transformAngularFile( + code, + actualId, + transformOptionsFor(actualId, !!options?.ssr, this as any), + emptyResources().resources, + ) + for (const error of result.errors) { + this.error(describeDiagnostic(error)) + } + for (const warning of result.warnings) { + this.warn(describeDiagnostic(warning)) + } + return { + code: result.code, + map: result.map ?? null, + } + } + // Resolve external resources. JIT output never reads them: the Rust // JIT transform turns `templateUrl`/`styleUrl(s)` into // `angular:jit:*` imports that `jitPlugin` loads, and it registers