Skip to content

Commit 38955a2

Browse files
committed
save
1 parent b1e7e5c commit 38955a2

18 files changed

+918
-373
lines changed

crates/oxc_angular_compiler/src/ast/r3.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ pub enum I18nMeta<'a> {
2929
/// An i18n message containing translatable content.
3030
#[derive(Debug)]
3131
pub struct I18nMessage<'a> {
32+
/// Unique instance ID for this message.
33+
///
34+
/// This ID is used to track message identity across moves/copies. It ensures that
35+
/// when an i18n attribute is copied (e.g., from element to conditional via
36+
/// `ingestControlFlowInsertionPoint`), both references can be linked to the same
37+
/// i18n context. Without this, Rust's move semantics would cause pointer-based
38+
/// identity checks to fail.
39+
///
40+
/// Assigned during parsing and must be unique per compilation unit.
41+
pub instance_id: u32,
3242
/// Message AST nodes.
3343
pub nodes: Vec<'a, I18nNode<'a>>,
3444
/// The meaning of the message (for disambiguation).
@@ -178,6 +188,9 @@ impl<'a> I18nMeta<'a> {
178188

179189
impl<'a> I18nMessage<'a> {
180190
/// Creates a deep clone of this i18n message using the provided allocator.
191+
///
192+
/// Note: This preserves the `instance_id` so that cloned messages maintain
193+
/// their identity for i18n context sharing.
181194
pub fn clone_in(&self, allocator: &'a Allocator) -> Self {
182195
let mut nodes = Vec::new_in(allocator);
183196
for node in self.nodes.iter() {
@@ -188,6 +201,7 @@ impl<'a> I18nMessage<'a> {
188201
legacy_ids.push(id.clone());
189202
}
190203
I18nMessage {
204+
instance_id: self.instance_id,
191205
nodes,
192206
meaning: self.meaning.clone(),
193207
description: self.description.clone(),

crates/oxc_angular_compiler/src/ir/ops.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,8 @@ pub struct AnimationStringOp<'a> {
18391839
pub target: XrefId,
18401840
/// Animation name.
18411841
pub name: Atom<'a>,
1842+
/// Animation kind (enter or leave).
1843+
pub animation_kind: AnimationKind,
18421844
/// Expression.
18431845
pub expression: Box<'a, IrExpression<'a>>,
18441846
}

crates/oxc_angular_compiler/src/pipeline/compilation.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ pub struct ComponentCompilationJob<'a> {
138138
pub is_i18n_template: bool,
139139
/// Metadata for i18n messages keyed by xref.
140140
pub i18n_message_metadata: FxHashMap<XrefId, I18nMessageMetadata<'a>>,
141+
/// Cache of i18n xrefs keyed by message identity (custom_id or computed id).
142+
///
143+
/// This ensures that when the same i18n message is encountered multiple times
144+
/// (e.g., when copying attributes from an element to its conditional), they
145+
/// share the same xref. This is crucial for correct const deduplication because
146+
/// the i18n_const_collection phase assigns i18n variable names (I18N_0, I18N_1)
147+
/// based on the context xref, which in turn depends on the message xref.
148+
pub i18n_xref_by_message_key: FxHashMap<String, XrefId>,
141149
/// Whether to use external message IDs in Closure Compiler variable names.
142150
///
143151
/// When true, generates variable names like `MSG_EXTERNAL_abc123$$SUFFIX`.
@@ -216,6 +224,7 @@ impl<'a> ComponentCompilationJob<'a> {
216224
mode: TemplateCompilationMode::default(),
217225
is_i18n_template: false,
218226
i18n_message_metadata: FxHashMap::default(),
227+
i18n_xref_by_message_key: FxHashMap::default(),
219228
i18n_use_external_ids: true, // Default matches Angular's JIT behavior
220229
relative_context_file_path: None,
221230
relocation_entries: Vec::new_in(allocator),
@@ -245,6 +254,23 @@ impl<'a> ComponentCompilationJob<'a> {
245254
id
246255
}
247256

257+
/// Gets or creates an i18n xref for a message with the given key.
258+
///
259+
/// This ensures that when the same i18n message is encountered multiple times
260+
/// (e.g., when copying attributes from an element to its conditional wrapper),
261+
/// they share the same xref. This is crucial for correct const deduplication.
262+
///
263+
/// The key should be derived from the message's identity (custom_id or computed id).
264+
pub fn get_or_create_i18n_xref(&mut self, message_key: String) -> XrefId {
265+
if let Some(&xref) = self.i18n_xref_by_message_key.get(&message_key) {
266+
xref
267+
} else {
268+
let xref = self.allocate_xref_id();
269+
self.i18n_xref_by_message_key.insert(message_key, xref);
270+
xref
271+
}
272+
}
273+
248274
/// Stores an expression and returns its ID.
249275
///
250276
/// Use this instead of inline expressions to avoid cloning.

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 204 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

crates/oxc_angular_compiler/src/pipeline/phases/assign_i18n_slot_dependencies.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,16 @@ fn assign_i18n_slot_dependencies_in_view(job: &mut ComponentCompilationJob<'_>,
154154
break;
155155
}
156156

157-
// Check if there's a next op to advance to
157+
// Advance to next update op (or None if this was the last)
158+
// Unlike TypeScript which uses sentinel nodes, we use None to represent
159+
// "past the end". When we insert at I18nEnd with cursor=None, we push
160+
// to the end of the list, which is correct (after all matching ops).
161+
update_op_ptr = next_ptr;
162+
163+
// If there's no next op, break after advancing the cursor
158164
if next_ptr.is_none() {
159165
break;
160166
}
161-
162-
// Advance to next update op
163-
update_op_ptr = next_ptr;
164167
}
165168
}
166169
}

0 commit comments

Comments
 (0)