@@ -1084,10 +1084,9 @@ fn ingest_element<'a>(
10841084 // Ingest static attributes (must happen BEFORE bound inputs for proper order)
10851085 // Static attributes are ingested as BindingOp with BindingKind::Attribute
10861086 // so that binding_specialization can detect ngNonBindable and other special attributes.
1087- let static_attrs: std:: vec:: Vec < ( Atom < ' a > , Atom < ' a > ) > =
1088- element. attributes . iter ( ) . map ( |attr| ( attr. name . clone ( ) , attr. value . clone ( ) ) ) . collect ( ) ;
1087+ // Note: We preserve i18n metadata for i18n-marked text attributes (e.g., tooltip="text" i18n-tooltip)
10891088 // Element attributes are not structural template attributes
1090- ingest_static_attributes ( job, view_xref, xref, static_attrs , false ) ;
1089+ ingest_static_attributes_with_i18n ( job, view_xref, xref, & element . attributes , false ) ;
10911090
10921091 // Ingest bindings BEFORE children to ensure update ops are in slot order.
10931092 // This matches Angular's TypeScript implementation in ingest.ts.
@@ -1255,9 +1254,11 @@ fn ingest_static_attributes<'a>(
12551254 let allocator = job. allocator ;
12561255
12571256 for ( name, value) in attributes {
1258- // ngNonBindable requires special handling: it must be added to the update list
1259- // as a BindingOp so binding_specialization can detect it
1260- if name. as_str ( ) == "ngNonBindable" {
1257+ // ngNonBindable and animate.* require special handling: they must be added to the
1258+ // update list as BindingOp so binding_specialization can detect and process them.
1259+ // - ngNonBindable: marks element as non-bindable
1260+ // - animate.*: converts to AnimationBindingOp for animation instructions
1261+ if name. as_str ( ) == "ngNonBindable" || name. as_str ( ) . starts_with ( "animate." ) {
12611262 let literal_expr = OutputExpression :: Literal ( Box :: new_in (
12621263 LiteralExpr { value : LiteralValue :: String ( value) , source_span : None } ,
12631264 allocator,
@@ -1316,6 +1317,146 @@ fn ingest_static_attributes<'a>(
13161317 }
13171318}
13181319
1320+ /// Ingests static attributes from R3TextAttribute, preserving i18n metadata.
1321+ ///
1322+ /// This version takes R3TextAttribute directly so it can access the i18n field.
1323+ /// For i18n-marked text attributes (e.g., `tooltip="text" i18n-tooltip="@@same-key"`),
1324+ /// we create an i18n_message xref to ensure proper context assignment in later phases.
1325+ ///
1326+ /// Ported from Angular's ingestElementBindings which passes attr.i18n to createBindingOp
1327+ /// (ingest.ts lines 1315-1332).
1328+ fn ingest_static_attributes_with_i18n < ' a > (
1329+ job : & mut ComponentCompilationJob < ' a > ,
1330+ view_xref : XrefId ,
1331+ element_xref : XrefId ,
1332+ attributes : & [ R3TextAttribute < ' a > ] ,
1333+ is_structural_template_attribute : bool ,
1334+ ) {
1335+ use crate :: output:: ast:: { LiteralExpr , LiteralValue , OutputExpression } ;
1336+
1337+ let allocator = job. allocator ;
1338+
1339+ for attr in attributes {
1340+ let name = attr. name . clone ( ) ;
1341+ let value = attr. value . clone ( ) ;
1342+
1343+ // ngNonBindable and animate.* require special handling
1344+ if name. as_str ( ) == "ngNonBindable" || name. as_str ( ) . starts_with ( "animate." ) {
1345+ let literal_expr = OutputExpression :: Literal ( Box :: new_in (
1346+ LiteralExpr { value : LiteralValue :: String ( value) , source_span : None } ,
1347+ allocator,
1348+ ) ) ;
1349+ let value_expr = IrExpression :: OutputExpr ( Box :: new_in ( literal_expr, allocator) ) ;
1350+
1351+ let binding = BindingOp {
1352+ base : UpdateOpBase :: default ( ) ,
1353+ target : element_xref,
1354+ kind : BindingKind :: Attribute ,
1355+ name,
1356+ expression : Box :: new_in ( value_expr, allocator) ,
1357+ unit : None ,
1358+ security_context : SecurityContext :: None ,
1359+ i18n_message : None ,
1360+ is_text_attribute : true ,
1361+ } ;
1362+
1363+ if let Some ( view) = job. view_mut ( view_xref) {
1364+ view. update . push ( UpdateOp :: Binding ( binding) ) ;
1365+ }
1366+ continue ;
1367+ }
1368+
1369+ // Handle i18n message if present (for i18n-* attribute markers)
1370+ // This matches Angular's asMessage(attr.i18n) in ingest.ts line 1329
1371+ //
1372+ // IMPORTANT: Use a cached xref based on the message's instance_id to ensure
1373+ // that when the SAME attribute is encountered twice (once for the conditional via
1374+ // ingestControlFlowInsertionPoint, once for the element via this function), both
1375+ // uses share the same xref. This matches TypeScript's behavior where Map keys use
1376+ // object identity.
1377+ //
1378+ // Different attributes (even with the same content) should get DIFFERENT xrefs,
1379+ // which is crucial for correct const deduplication - each element with an i18n
1380+ // attribute should get its own const entry.
1381+ //
1382+ // We use instance_id rather than pointer address because Rust moves data around
1383+ // during iteration (e.g., `for child in branch.children` moves the children),
1384+ // which changes memory addresses. The instance_id is assigned during parsing
1385+ // and survives moves.
1386+ let i18n_message = if let Some ( I18nMeta :: Message ( ref message) ) = attr. i18n {
1387+ // Use the instance ID as the cache key (survives Rust moves unlike pointer)
1388+ let message_key = format ! ( "i18n_instance_{}" , message. instance_id) ;
1389+
1390+ let i18n_xref = job. get_or_create_i18n_xref ( message_key) ;
1391+
1392+ // Store i18n message metadata for later phases (only if not already stored)
1393+ if !job. i18n_message_metadata . contains_key ( & i18n_xref) {
1394+ let mut legacy_ids = Vec :: new_in ( allocator) ;
1395+ for id in message. legacy_ids . iter ( ) {
1396+ legacy_ids. push ( id. clone ( ) ) ;
1397+ }
1398+
1399+ let metadata = I18nMessageMetadata {
1400+ message_id : if message. id . is_empty ( ) { None } else { Some ( message. id . clone ( ) ) } ,
1401+ custom_id : if message. custom_id . is_empty ( ) {
1402+ None
1403+ } else {
1404+ Some ( message. custom_id . clone ( ) )
1405+ } ,
1406+ meaning : if message. meaning . is_empty ( ) {
1407+ None
1408+ } else {
1409+ Some ( message. meaning . clone ( ) )
1410+ } ,
1411+ description : if message. description . is_empty ( ) {
1412+ None
1413+ } else {
1414+ Some ( message. description . clone ( ) )
1415+ } ,
1416+ legacy_ids,
1417+ } ;
1418+ job. i18n_message_metadata . insert ( i18n_xref, metadata) ;
1419+ }
1420+
1421+ Some ( i18n_xref)
1422+ } else {
1423+ None
1424+ } ;
1425+
1426+ // All other static attributes go to the create list as ExtractedAttributeOp
1427+ let literal_expr = OutputExpression :: Literal ( Box :: new_in (
1428+ LiteralExpr { value : LiteralValue :: String ( value) , source_span : None } ,
1429+ allocator,
1430+ ) ) ;
1431+ let value_expr = IrExpression :: OutputExpr ( Box :: new_in ( literal_expr, allocator) ) ;
1432+
1433+ // Use Template kind for structural template attributes, Attribute otherwise
1434+ let binding_kind = if is_structural_template_attribute {
1435+ BindingKind :: Template
1436+ } else {
1437+ BindingKind :: Attribute
1438+ } ;
1439+
1440+ let extracted = ExtractedAttributeOp {
1441+ base : CreateOpBase :: default ( ) ,
1442+ target : element_xref,
1443+ binding_kind,
1444+ namespace : None ,
1445+ name,
1446+ value : Some ( Box :: new_in ( value_expr, allocator) ) ,
1447+ security_context : SecurityContext :: None ,
1448+ truthy_expression : false ,
1449+ i18n_context : None ,
1450+ i18n_message,
1451+ trusted_value_fn : None ,
1452+ } ;
1453+
1454+ if let Some ( view) = job. view_mut ( view_xref) {
1455+ view. create . push ( CreateOp :: ExtractedAttribute ( extracted) ) ;
1456+ }
1457+ }
1458+ }
1459+
13191460/// Ingests a single static attribute.
13201461///
13211462/// This is used for processing template_attrs in order, where we need to handle
@@ -4066,6 +4207,62 @@ fn ingest_control_flow_insertion_point<'a, 'b>(
40664207 let security_context = crate :: schema:: get_security_context ( NG_TEMPLATE_TAG_NAME , attr_name) ;
40674208 let value_expr = create_string_literal_atom ( allocator, attr. value . clone ( ) ) ;
40684209
4210+ // Handle i18n message if present (for i18n-* attribute markers)
4211+ // This matches Angular's asMessage(attr.i18n) in ingest.ts line 1879
4212+ //
4213+ // IMPORTANT: Use a cached xref based on MESSAGE INSTANCE ID to ensure that when
4214+ // the SAME attribute is encountered twice (once for the conditional via
4215+ // ingestControlFlowInsertionPoint, once for the element via ingestStaticAttributes),
4216+ // both uses share the same xref. This matches TypeScript's behavior where Map keys
4217+ // use object identity.
4218+ //
4219+ // Each i18n message has a unique instance_id assigned during parsing. This survives
4220+ // moves/copies and ensures correct identity tracking even after Rust's move semantics
4221+ // relocate the data.
4222+ //
4223+ // Different attributes (even with the same content) should get DIFFERENT xrefs,
4224+ // which is crucial for correct const deduplication - each element with an i18n
4225+ // attribute should get its own const entry.
4226+ let i18n_message = if let Some ( I18nMeta :: Message ( ref message) ) = attr. i18n {
4227+ // Use the instance ID as the cache key
4228+ let message_key = format ! ( "i18n_instance_{}" , message. instance_id) ;
4229+
4230+ let i18n_xref = job. get_or_create_i18n_xref ( message_key) ;
4231+
4232+ // Store i18n message metadata for later phases (only if not already stored)
4233+ if !job. i18n_message_metadata . contains_key ( & i18n_xref) {
4234+ let mut legacy_ids = Vec :: new_in ( allocator) ;
4235+ for id in message. legacy_ids . iter ( ) {
4236+ legacy_ids. push ( id. clone ( ) ) ;
4237+ }
4238+
4239+ let metadata = I18nMessageMetadata {
4240+ message_id : if message. id . is_empty ( ) { None } else { Some ( message. id . clone ( ) ) } ,
4241+ custom_id : if message. custom_id . is_empty ( ) {
4242+ None
4243+ } else {
4244+ Some ( message. custom_id . clone ( ) )
4245+ } ,
4246+ meaning : if message. meaning . is_empty ( ) {
4247+ None
4248+ } else {
4249+ Some ( message. meaning . clone ( ) )
4250+ } ,
4251+ description : if message. description . is_empty ( ) {
4252+ None
4253+ } else {
4254+ Some ( message. description . clone ( ) )
4255+ } ,
4256+ legacy_ids,
4257+ } ;
4258+ job. i18n_message_metadata . insert ( i18n_xref, metadata) ;
4259+ }
4260+
4261+ Some ( i18n_xref)
4262+ } else {
4263+ None
4264+ } ;
4265+
40694266 let binding_op = UpdateOp :: Binding ( BindingOp {
40704267 base : UpdateOpBase { source_span : Some ( attr. source_span ) , ..Default :: default ( ) } ,
40714268 target : xref,
@@ -4074,7 +4271,7 @@ fn ingest_control_flow_insertion_point<'a, 'b>(
40744271 expression : Box :: new_in ( value_expr, allocator) ,
40754272 unit : None ,
40764273 security_context,
4077- i18n_message : None , // TODO: handle attr.i18n
4274+ i18n_message,
40784275 is_text_attribute : true , // Static attributes are text attributes
40794276 } ) ;
40804277
0 commit comments