Skip to content

Commit 6493f4e

Browse files
committed
make e2e stable
1 parent 6077370 commit 6493f4e

File tree

19 files changed

+338
-66
lines changed

19 files changed

+338
-66
lines changed

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 127 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//! Ported from Angular's `template/pipeline/src/ingest.ts`.
1616
1717
use oxc_allocator::{Allocator, Box, Vec};
18+
use oxc_diagnostics::OxcDiagnostic;
1819
use oxc_span::Atom;
1920

2021
use super::compilation::{
@@ -23,9 +24,10 @@ use super::compilation::{
2324
};
2425
use crate::ast::expression::AngularExpression;
2526
use crate::ast::r3::{
26-
I18nMeta, I18nNode, R3BoundAttribute, R3BoundEvent, R3BoundText, R3Content, R3DeferredBlock,
27-
R3Element, R3ForLoopBlock, R3IfBlock, R3LetDeclaration, R3Node, R3SwitchBlock, R3Template,
28-
R3Text, R3TextAttribute, R3Variable, SecurityContext,
27+
I18nIcuPlaceholder, I18nMeta, I18nNode, R3BoundAttribute, R3BoundEvent, R3BoundText, R3Content,
28+
R3DeferredBlock, R3Element, R3ForLoopBlock, R3Icu, R3IcuPlaceholder, R3IfBlock,
29+
R3LetDeclaration, R3Node, R3SwitchBlock, R3Template, R3Text, R3TextAttribute, R3Variable,
30+
SecurityContext,
2931
};
3032
use crate::ir::enums::{
3133
BindingKind, DeferOpModifierKind, DeferTriggerKind, Namespace, SemanticVariableKind,
@@ -38,9 +40,9 @@ use crate::ir::expression::{
3840
use crate::ir::ops::{
3941
BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, ControlCreateOp,
4042
CreateOp, CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp, ElementOp,
41-
ElementStartOp, ExtractedAttributeOp, I18nPlaceholder, InterpolateTextOp, ListenerOp, LocalRef,
42-
ProjectionOp, RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StoreLetOp, TemplateOp,
43-
TextOp, UpdateOp, UpdateOpBase, VariableOp, XrefId,
43+
ElementStartOp, ExtractedAttributeOp, I18nPlaceholder, IcuEndOp, IcuStartOp, InterpolateTextOp,
44+
ListenerOp, LocalRef, ProjectionOp, RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId,
45+
StoreLetOp, TemplateOp, TextOp, UpdateOp, UpdateOpBase, VariableOp, XrefId,
4446
};
4547
use crate::output::ast::OutputExpression;
4648
use crate::pipeline::compilation::{AliasVariable, ContextVariable};
@@ -395,8 +397,10 @@ pub fn ingest_component_with_options<'a>(
395397
/// Consumes the node, taking ownership of expressions.
396398
fn ingest_node<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, node: R3Node<'a>) {
397399
match node {
398-
R3Node::Text(text) => ingest_text(job, view_xref, text.unbox()),
399-
R3Node::BoundText(bound_text) => ingest_bound_text(job, view_xref, bound_text.unbox()),
400+
R3Node::Text(text) => ingest_text(job, view_xref, text.unbox(), None),
401+
R3Node::BoundText(bound_text) => {
402+
ingest_bound_text(job, view_xref, bound_text.unbox(), None)
403+
}
400404
R3Node::Element(element) => ingest_element(job, view_xref, element.unbox()),
401405
R3Node::Template(template) => ingest_template(job, view_xref, template.unbox()),
402406
R3Node::Content(content) => ingest_content(job, view_xref, content.unbox()),
@@ -414,9 +418,7 @@ fn ingest_node<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, nod
414418
R3Node::Comment(_) => {
415419
// Comments are not ingested into IR
416420
}
417-
R3Node::Icu(_) => {
418-
// ICU expressions are handled separately
419-
}
421+
R3Node::Icu(icu) => ingest_icu(job, view_xref, icu.unbox()),
420422
R3Node::UnknownBlock(_) => {
421423
// Unknown blocks are skipped with a warning
422424
}
@@ -445,16 +447,24 @@ fn ingest_node<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, nod
445447
}
446448

447449
/// Ingests a static text node.
448-
fn ingest_text<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, text: R3Text<'a>) {
450+
///
451+
/// `icu_placeholder` is provided when this text is part of an ICU expression,
452+
/// indicating the placeholder name for this text within the ICU message.
453+
fn ingest_text<'a>(
454+
job: &mut ComponentCompilationJob<'a>,
455+
view_xref: XrefId,
456+
text: R3Text<'a>,
457+
icu_placeholder: Option<Atom<'a>>,
458+
) {
449459
let xref = job.allocate_xref_id();
450460

451461
let op = CreateOp::Text(TextOp {
452462
base: CreateOpBase { source_span: Some(text.source_span), ..Default::default() },
453463
xref,
454464
slot: None,
455-
initial_value: text.value, // Move instead of clone
465+
initial_value: text.value,
456466
i18n_placeholder: None,
457-
icu_placeholder: None,
467+
icu_placeholder,
458468
});
459469

460470
if let Some(view) = job.view_mut(view_xref) {
@@ -463,10 +473,14 @@ fn ingest_text<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, tex
463473
}
464474

465475
/// Ingests a bound text node (with interpolation).
476+
///
477+
/// `icu_placeholder` is provided when this text is part of an ICU expression,
478+
/// indicating the placeholder name for this text within the ICU message.
466479
fn ingest_bound_text<'a>(
467480
job: &mut ComponentCompilationJob<'a>,
468481
view_xref: XrefId,
469482
bound_text: R3BoundText<'a>,
483+
icu_placeholder: Option<Atom<'a>>,
470484
) {
471485
let xref = job.allocate_xref_id();
472486

@@ -477,7 +491,7 @@ fn ingest_bound_text<'a>(
477491
slot: None,
478492
initial_value: Atom::from(""),
479493
i18n_placeholder: None,
480-
icu_placeholder: None,
494+
icu_placeholder,
481495
});
482496

483497
if let Some(view) = job.view_mut(view_xref) {
@@ -501,6 +515,86 @@ fn ingest_bound_text<'a>(
501515
}
502516
}
503517

518+
/// Checks if the i18n metadata is a Message containing a single IcuPlaceholder.
519+
/// Returns the ICU placeholder if so, None otherwise.
520+
fn get_single_icu_placeholder<'a, 'b>(
521+
meta: &'b Option<I18nMeta<'a>>,
522+
) -> Option<&'b I18nIcuPlaceholder<'a>> {
523+
if let Some(I18nMeta::Message(message)) = meta {
524+
if message.nodes.len() == 1 {
525+
if let I18nNode::IcuPlaceholder(icu_placeholder) = &message.nodes[0] {
526+
return Some(icu_placeholder);
527+
}
528+
}
529+
}
530+
None
531+
}
532+
533+
/// Ingests an ICU expression node (plural, select, selectordinal).
534+
///
535+
/// Creates IcuStartOp and IcuEndOp to bracket the ICU expression,
536+
/// and ingests all vars and placeholders within.
537+
///
538+
/// Ported from Angular's `ingestIcu` in `template/pipeline/src/ingest.ts`.
539+
fn ingest_icu<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, icu: R3Icu<'a>) {
540+
// Check if the i18n metadata is a Message with a single IcuPlaceholder
541+
// TypeScript: if (icu.i18n instanceof i18n.Message && isSingleI18nIcu(icu.i18n))
542+
let icu_placeholder_name = match get_single_icu_placeholder(&icu.i18n) {
543+
Some(icu_placeholder) => icu_placeholder.name.clone(),
544+
None => {
545+
// TypeScript throws: Error(`Unhandled i18n metadata type for ICU: ${icu.i18n?.constructor.name}`)
546+
// We report as a diagnostic and return early
547+
job.diagnostics.push(OxcDiagnostic::error(
548+
"Unhandled i18n metadata type for ICU: expected Message with single IcuPlaceholder",
549+
).with_label(icu.source_span));
550+
return;
551+
}
552+
};
553+
554+
let xref = job.allocate_xref_id();
555+
556+
// Create IcuStartOp
557+
let start_op = CreateOp::IcuStart(IcuStartOp {
558+
base: CreateOpBase { source_span: Some(icu.source_span), ..Default::default() },
559+
xref,
560+
context: None, // Will be set by create_i18n_contexts phase
561+
message: None, // Will be set by phases
562+
icu_placeholder: Some(icu_placeholder_name),
563+
});
564+
565+
if let Some(view) = job.view_mut(view_xref) {
566+
view.create.push(start_op);
567+
}
568+
569+
// Process vars (bound text expressions)
570+
// In Rust, vars is typed as HashMap<Atom, R3BoundText> so no runtime check needed
571+
for (placeholder_name, bound_text) in icu.vars {
572+
ingest_bound_text(job, view_xref, bound_text, Some(placeholder_name));
573+
}
574+
575+
// Process placeholders (text or bound text)
576+
for (placeholder_name, placeholder) in icu.placeholders {
577+
match placeholder {
578+
R3IcuPlaceholder::Text(text) => {
579+
ingest_text(job, view_xref, text, Some(placeholder_name));
580+
}
581+
R3IcuPlaceholder::BoundText(bound_text) => {
582+
ingest_bound_text(job, view_xref, bound_text, Some(placeholder_name));
583+
}
584+
}
585+
}
586+
587+
// Create IcuEndOp
588+
let end_op = CreateOp::IcuEnd(IcuEndOp {
589+
base: CreateOpBase { source_span: Some(icu.source_span), ..Default::default() },
590+
xref,
591+
});
592+
593+
if let Some(view) = job.view_mut(view_xref) {
594+
view.create.push(end_op);
595+
}
596+
}
597+
504598
/// Splits a namespaced name like `:svg:path` into (namespace_key, element_name).
505599
///
506600
/// Ported from Angular's `splitNsName` in `src/ml_parser/tags.ts`.
@@ -1581,8 +1675,20 @@ fn ingest_defer_block<'a>(
15811675
DeferMetadata::PerBlock { blocks } => {
15821676
// In PerBlock mode, look up the resolver from the blocks map using source_span
15831677
// Use remove() to take ownership (move) since we can't clone OutputExpression
1584-
// If not found, the block has no lazy dependencies (own_resolver_fn is null)
1585-
blocks.remove(&defer_block.source_span).flatten()
1678+
// TypeScript throws if the block is not in the map at all
1679+
match blocks.remove(&defer_block.source_span) {
1680+
Some(value) => {
1681+
// Key exists - value may be None (no lazy deps) or Some (has deps)
1682+
value
1683+
}
1684+
None => {
1685+
// TypeScript: throw Error(`AssertionError: unable to find a dependency function for this deferred block`)
1686+
job.diagnostics.push(OxcDiagnostic::error(
1687+
"AssertionError: unable to find a dependency function for this deferred block",
1688+
).with_label(defer_block.source_span));
1689+
None
1690+
}
1691+
}
15861692
}
15871693
DeferMetadata::PerComponent { .. } => {
15881694
// In PerComponent mode, own_resolver_fn is null
@@ -1591,10 +1697,10 @@ fn ingest_defer_block<'a>(
15911697
}
15921698
};
15931699

1594-
// For resolver_fn, take ownership from all_deferrable_deps_fn
1595-
// Note: In PerComponent mode, all defer blocks share this, so we take it only once
1596-
// and leave None for subsequent blocks (they'll get resolver_fn set by resolve_defer_deps_fns)
1597-
let resolver_fn = job.all_deferrable_deps_fn.take();
1700+
// In PerComponent mode, all defer blocks share the same allDeferrableDepsFn reference.
1701+
// We clone it so each defer block gets its own copy (matching TypeScript's behavior
1702+
// where ReadVarExpr is shared by reference).
1703+
let resolver_fn = job.all_deferrable_deps_fn.as_ref().map(|expr| expr.clone_in(job.allocator));
15981704

15991705
let op = CreateOp::Defer(DeferOp {
16001706
base: CreateOpBase { source_span: Some(defer_block.source_span), ..Default::default() },

napi/angular-compiler/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/dist
22
/playground/dist
3+
/test-results
4+
/playwright-report
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Angular E2E Fixture</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
</head>
8+
<body>
9+
<app-root></app-root>
10+
<script type="module" src="/src/main.ts"></script>
11+
</body>
12+
</html>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "oxc-angular-e2e-fixture",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite"
8+
},
9+
"dependencies": {
10+
"@angular/common": "^21.0.0",
11+
"@angular/compiler": "^21.0.0",
12+
"@angular/core": "^21.0.0",
13+
"@angular/platform-browser": "^21.0.0",
14+
"rxjs": "^7.8.2",
15+
"tslib": "^2.8.1"
16+
},
17+
"devDependencies": {
18+
"@angular/compiler-cli": "^21.0.0",
19+
"@oxc/vite-plugin-angular": "workspace:^",
20+
"typescript": "^5.9.3",
21+
"vite": "^7.3.0"
22+
}
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Component, signal } from "@angular/core";
2+
3+
@Component({
4+
selector: "app-root",
5+
templateUrl: "./app.html",
6+
styleUrl: "./app.css",
7+
})
8+
export class App {
9+
protected readonly title = signal("E2E_TITLE");
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
:host {
2+
--primary-color: blue;
3+
--text-color: black;
4+
display: block;
5+
font-family: sans-serif;
6+
padding: 20px;
7+
}
8+
9+
h1 {
10+
color: var(--primary-color);
11+
font-size: 2rem;
12+
margin: 0 0 10px 0;
13+
}
14+
15+
.description {
16+
color: var(--text-color);
17+
margin: 0;
18+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<main>
2+
<h1>{{ title() }}</h1>
3+
<p class="description">E2E test fixture for HMR testing.</p>
4+
</main>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import "@angular/compiler";
2+
3+
import { bootstrapApplication } from "@angular/platform-browser";
4+
import { App } from "./app/app.component";
5+
6+
bootstrapApplication(App).catch((err) => console.error(err));
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compileOnSave": false,
3+
"compilerOptions": {
4+
"strict": true,
5+
"noImplicitOverride": true,
6+
"noPropertyAccessFromIndexSignature": true,
7+
"noImplicitReturns": true,
8+
"noFallthroughCasesInSwitch": true,
9+
"skipLibCheck": true,
10+
"isolatedModules": true,
11+
"experimentalDecorators": true,
12+
"importHelpers": true,
13+
"target": "ES2022",
14+
"module": "preserve",
15+
"moduleResolution": "bundler"
16+
},
17+
"angularCompilerOptions": {
18+
"enableI18nLegacyMessageIdFormat": false,
19+
"strictInjectionParameters": true,
20+
"strictInputAccessModifiers": true,
21+
"strictTemplates": true
22+
},
23+
"include": ["src/**/*.ts"]
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import path from "node:path";
2+
import { defineConfig } from "vite";
3+
import { fileURLToPath } from "node:url";
4+
5+
import { angular } from "@oxc/vite-plugin-angular/vite-plugin";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const tsconfig = path.resolve(__dirname, "./tsconfig.json");
10+
11+
export default defineConfig({
12+
plugins: [
13+
angular({
14+
tsconfig,
15+
liveReload: true,
16+
}),
17+
],
18+
});

0 commit comments

Comments
 (0)