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 5c735ffa58..46c611dd41 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, _ => { @@ -1339,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)?; @@ -1397,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/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index fbf2019156..82b38651b0 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; @@ -925,6 +937,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 +954,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 +977,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 +997,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/definition.rs b/crates/oxc_angular_compiler/src/component/definition.rs index 2e5e61d963..402d1bf1a5 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; @@ -73,6 +74,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 +95,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 +128,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 +280,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 +1010,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 +1062,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); @@ -1176,39 +1185,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. @@ -1691,8 +1698,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 +1736,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 +1762,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 +1788,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 +1813,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 +1838,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 +1864,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 +1891,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 +1919,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 +1950,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 +1980,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 +2020,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 +2057,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 +2358,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/dependency.rs b/crates/oxc_angular_compiler/src/component/dependency.rs index e3575ab87f..d88027c8d8 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/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 9aaf07cf24..5912678f2a 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -451,6 +451,47 @@ 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() + } + + /// 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. /// @@ -816,7 +857,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 +866,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 +899,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, @@ -1259,6 +1313,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/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 658042b1fd..998c189fd3 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/namespace_registry.rs b/crates/oxc_angular_compiler/src/component/namespace_registry.rs index 6b95a48d76..d619760c85 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 b9b2ba12ad..c9a58f241b 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; @@ -528,6 +529,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. @@ -937,32 +943,67 @@ 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(), } } -/// `i1.name` for the export `name` of `specifier` in the `.d.ts`. +/// 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`, 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`. /// /// 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 +1012,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, + ); } } @@ -2456,6 +2493,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 +3339,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 +3409,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 } @@ -3969,7 +4031,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 @@ -4491,12 +4559,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 +4673,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 { @@ -4875,11 +4956,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 +4977,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), ) }; @@ -5376,8 +5457,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)); } } @@ -5690,6 +5770,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`, @@ -5737,12 +5819,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 +5837,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 +5854,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 +5889,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) } @@ -6256,6 +6367,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()), @@ -6281,6 +6393,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>], @@ -6290,15 +6403,31 @@ 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, + template_source, is_inline: metadata.template.is_some(), content_queries, 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 +6435,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 @@ -7070,10 +7192,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, @@ -7208,6 +7342,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 +7561,7 @@ fn compile_component_full<'a>( view_query_fn, content_queries_fn, namespace_registry, + template_animations_feature, ); // Emit JavaScript @@ -7459,6 +7599,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 +8327,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 +8481,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 +9004,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 +9088,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 +9232,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/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 80445886f7..4de0ba8bac 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 7efed583a3..3808af06a9 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}; @@ -187,8 +188,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)), @@ -341,6 +352,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 +378,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 +405,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, @@ -489,7 +507,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. @@ -888,7 +906,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 +933,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, } @@ -1211,6 +1231,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 +1563,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 +2028,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 +2052,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,11 +2153,40 @@ 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. - for (key, _) in &entries { + 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. + // 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(); + 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 { 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."); @@ -2137,10 +2204,10 @@ fn evaluate_host_metadata_uncached<'a>( by the current module." )); } - return Err(msg); + errors.push(msg); } } - Ok(entries) + (!errors.is_empty()).then(|| errors.join("\n")) } /// The error ngtsc reports for the `host` of a `@Component` / `@Directive` whose @@ -2150,11 +2217,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. @@ -2192,8 +2263,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 +2342,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 e55d73184e..8e39be9b9a 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/metadata.rs b/crates/oxc_angular_compiler/src/directive/metadata.rs index 894fdfe69a..f380f4fb20 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 4ead4cf8af..6ffc1e221b 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::{ @@ -48,6 +51,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/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index 94a711890d..c4202e2b4e 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()); @@ -840,6 +829,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 +868,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 1d831f43bd..a0c2981f3e 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/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index d19d9098c6..9eb340e3af 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/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 60867a9a41..6d5014fcc0 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 4d40adbccc..282a708c91 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; @@ -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(key, false) } 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. @@ -574,7 +579,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 +999,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 +1040,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 +1072,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 +1083,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 +1446,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 +1567,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 +1611,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 +1702,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 +1782,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") @@ -1721,7 +1798,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}")); } @@ -1951,7 +2028,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 { @@ -2158,6 +2236,7 @@ fn build_features( source: &str, ns: &str, angular_version: Option, + template_animations_feature: bool, ) -> Option { let mut features: Vec = Vec::new(); @@ -2178,7 +2257,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})")); } @@ -2197,15 +2278,17 @@ 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})")); } // 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()")); } @@ -2213,6 +2296,106 @@ 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) = host_directive_entry_property(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) = host_directive_entry_property(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_for_linker(field), escape_string_for_linker(alias)] + }) + .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 property of a partial host directive entry, read like Angular's linker +/// reads an object literal: the key may be a name or a quoted string, and when +/// a key is written twice the last one wins. +fn host_directive_entry_property<'a>( + 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 +/// 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 { @@ -2346,7 +2529,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 +2600,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}")); } @@ -2439,7 +2622,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}")); } @@ -2675,9 +2864,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 +2886,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 0d04911a32..cf59f1a3ed 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>>, @@ -80,6 +82,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 +118,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 +197,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 +347,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 +561,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 +587,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 +614,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/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index a7ea162cfb..846c9ebbc1 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 32ce209f78..f3fca54916 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,14 @@ //! `ɵɵ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 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 @@ -278,20 +281,44 @@ pub fn top_level_const_init<'b, 'a>( program: &'b Program<'a>, name: &str, ) -> Option<&'b Expression<'a>> { + top_level_init(program, name, false).map(|(init, _)| init) +} + +/// The initializer of the top-level `const`, `let` or `var` `name` of +/// `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) +} + +fn top_level_init<'b, 'a>( + program: &'b Program<'a>, + name: &str, + any_kind: bool, +) -> Option<(&'b Expression<'a>, u32)> { + 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 + 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)) }) } @@ -360,6 +387,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, } @@ -431,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, } @@ -472,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) @@ -500,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]; @@ -523,26 +560,23 @@ 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), + ), }); } } 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 +630,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>, @@ -607,6 +691,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> { @@ -665,18 +755,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) { - Some(init) => match evaluated(init) { - Expression::ArrayExpression(array) => self.array(array, depth + 1), + Expression::Identifier(id) => match top_level_var_init(self.program, &id.name) { + 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), }, - 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 +777,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 +793,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,20 +810,29 @@ 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, 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(...)`. @@ -739,57 +843,44 @@ 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(() => 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 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. 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 { + let Some(target) = forward_ref_target(call) 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 { - 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. @@ -799,7 +890,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), } @@ -842,8 +936,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/src/output/ast.rs b/crates/oxc_angular_compiler/src/output/ast.rs index bcf9743357..defa05713f 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( @@ -1736,6 +1746,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/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index 7a14715226..9941bdd79d 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 82fe737bab..e61e245d60 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 671b731a00..1e94f25261 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, @@ -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, @@ -78,6 +83,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 +223,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, )); } @@ -233,7 +247,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, )); @@ -648,15 +665,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 +688,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 +710,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 e71a72bac3..c052e208cb 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/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 9e7c3077e0..c516bf8646 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 c2f4ecb5b9..d907f9669d 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/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 79aba6fe2a..c6d1d38295 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 8a5d8ac08a..7c02ce456e 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( @@ -422,6 +427,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 +504,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/chaining.rs b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs index 3767b38979..d91b00ae16 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/convert_animations.rs b/crates/oxc_angular_compiler/src/pipeline/phases/convert_animations.rs index 523bc62d1a..20c0f46c15 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/pipeline/phases/reify/ir_expression.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/ir_expression.rs index 4770af9e03..760b7985b5 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/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index 23555c67a1..20947122c3 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 33312a90e3..6f0a95a870 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/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index 8b09a82317..69a106bc9b 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/dts_type_reference_test.rs b/crates/oxc_angular_compiler/tests/dts_type_reference_test.rs index 2174d59a2c..37adf5e64c 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/hmr_component_update_test.rs b/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs index 0afd0e6c3a..56e818ac61 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/host_aria_binding_test.rs b/crates/oxc_angular_compiler/tests/host_aria_binding_test.rs new file mode 100644 index 0000000000..517479dcc1 --- /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);"# + ); +} 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 0000000000..e71780ba65 --- /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/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs b/crates/oxc_angular_compiler/tests/host_metadata_evaluation_test.rs index 2322ddbc6e..26493e9d49 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 = '';"; @@ -700,6 +700,123 @@ 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}"), + ); +} + +/// 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 +/// 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/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 2ea7046964..39bfa2315d 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. @@ -19400,3 +19389,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 bc242dc98e..bab0de545b 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(); @@ -1342,6 +1343,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() { @@ -1478,3 +1498,167 @@ 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}"); + } +} + +/// `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"] }]"#), + ), + // 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!( + 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}"); + } +} + +/// 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}"); + } +} 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 a6997ea15d..67d68595c1 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/ng_module_injector_imports_test.rs b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs index 68f5748acf..fe675fd8fc 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 0000000000..259c73dd16 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/ng_module_list_reading_test.rs @@ -0,0 +1,482 @@ +//! 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]; +"; + +// `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'; +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]; +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 { + 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, "src/dep3.ts", DEP3); + 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 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 + // 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]", + ); +} + +#[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"#, + ); +} 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 0000000000..46f21c9ccc --- /dev/null +++ b/crates/oxc_angular_compiler/tests/non_ascii_output_test.rs @@ -0,0 +1,442 @@ +//! 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)"]); +} + +#[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] +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ü' }])", + ], + ); +} + +#[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 { + 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"]); +} 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 19a16ae0dc..dcfd1a99df 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}" + ); + } + } } } diff --git a/napi/angular-compiler/.gitignore b/napi/angular-compiler/.gitignore index ac27bae0fa..207ddb06ef 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/README.md b/napi/angular-compiler/README.md index c89b3a89a8..9778faea05 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,68 @@ 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' } }) +``` + +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 +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 +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`. Like the Vite build, a component's `ngContentSelectors` +is `never`, where ngc lists the selectors. + ## Vite Plugin Architecture The Vite plugin consists of these sub-plugins: diff --git a/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts b/napi/angular-compiler/e2e/tests/ssr-manifest.spec.ts index eeffcbc04f..339a35191f 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/package.json b/napi/angular-compiler/package.json index 64c7095cae..a5cdcc93ad 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/src/lib.rs b/napi/angular-compiler/src/lib.rs index 165b98f9d2..86c403228e 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, 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 0000000000..34f408a463 --- /dev/null +++ b/napi/angular-compiler/test/dts-augment.test.ts @@ -0,0 +1,595 @@ +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, sep } 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 { + 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 { + 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[rel(dir, file)] = read(file) + } + return out +} + +function mappings(file: string): string { + return JSON.parse(read(file)).mappings +} + +beforeAll(() => { + // 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(() => { + 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) => 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) + } + // 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) => rel(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 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') + + 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/') + 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(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( + 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) => rel(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) => 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'), + ) + } + 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/) + }) + + 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/host-directives-runtime.test.ts b/napi/angular-compiler/test/host-directives-runtime.test.ts new file mode 100644 index 0000000000..aa21fba107 --- /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 }) + }) +}) 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 0000000000..233be2bc48 --- /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/) + }) +}) diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index d4e5b1585c..b5f51e3765 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, 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' /** @@ -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 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 })), + ) /** 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,66 @@ 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) + // The plugin removes its dir only on a normal process exit. + stamps.push(dirname(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), + 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)[]> { - 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 +278,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 +367,266 @@ 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 + 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}$/) + // 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') + + // 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('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') + // 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. + for (const name of names) process.env[name] = 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 { + for (const [i, name] of names.entries()) { + if (saved[i] === undefined) delete process.env[name] + else process.env[name] = saved[i] + } + } + }) + + 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 +635,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/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 0b2edc8d44..0c85b3c34b 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') 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 861c7515f3..24fe9bf102 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,12 @@ * @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 { 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' -import type { Plugin, ResolvedConfig, Rolldown } from 'vite' +import type { Plugin, ResolvedConfig, Rolldown, ViteBuilder } from 'vite' import { normalizePath } from 'vite' /** @@ -109,6 +111,154 @@ 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 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)) + 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 +271,45 @@ 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 + // 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, building: false, 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 +328,151 @@ 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) { - if (this.environment?.config.consumer !== 'client') return + async handler(_, bundle) { + 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(environment)) return + + if (asset?.type === 'asset') clientPage = sourceText(asset.source) + // 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.building && + 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. + buildEnd(error) { + 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) 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 + if (isAppClient(this.environment)) endClientRound(true) + const server = servers.get(this.environment.name) + if (server) server.building = false + }, + + 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. + const server = serverState(name) + server.warned = false + server.building = true + if (!stampPath && !stampFailed) { + try { + stampPath = createStampFile() + } catch (error) { + stampFailed = true + this.warn( + `could not make a file in ${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 +497,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 +525,55 @@ 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-- + } + + server.building = false + 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, + } + }, } } 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 0000000000..3121f2de8f --- /dev/null +++ b/napi/angular-compiler/vite-plugin/dts-augment.ts @@ -0,0 +1,542 @@ +/** + * 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, stat, 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 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 found) { + 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 + // Whether `base` is the text `tsc` writes for these options. + let fresh = false + if (emitted) { + mapFile = emitted.mapFile + 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 if (mapFile) { + baseMap = await readText(mapFile) + } + } + + // 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 : '' + let code = base.slice(bom.length) + 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; + // 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 + if (injected.map) { + map = injected.map.json + mapFile ??= resolve(dirname(file), injected.map.ref) + } + } + } + if (crlf) { + code = mark + ? code.replace(/(? { + 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 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) { + 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) + +// See `augmentTarget`: put in front of a lone `\n` while the splice runs. +const LONE_LF_MARK = '\0' + +// 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;\r?$/ + +/** + * Whether `a` and `b` are the same text once the lines the splice adds are + * removed. Line breaks must match too: `\r\n` is not the same as `\n`. + */ +function sameApartFromIvyMembers(a: string, b: string): boolean { + const strip = (text: string) => + text + .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 + } +}