diff --git a/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs b/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs index b49c4d184..6f535f591 100644 --- a/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs +++ b/crates/oxc_angular_compiler/src/class_debug_info/metadata.rs @@ -62,3 +62,56 @@ impl<'a> R3ClassDebugInfo<'a> { self } } + +/// The 1-indexed line of byte `offset` in `source`, counting line breaks the +/// way TypeScript's `computeLineStarts` does: `\r\n`, `\r`, `\n`, U+2028 and +/// U+2029 each end a line. +/// +/// ngtsc computes `ɵsetClassDebugInfo`'s `lineNumber` with +/// `getLineAndCharacterOfPosition`, which uses those line starts. +pub fn line_number_at(source: &str, offset: usize) -> u32 { + let bytes = &source.as_bytes()[..offset.min(source.len())]; + let mut line = 1u32; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\r' => { + line += 1; + if bytes.get(i + 1) == Some(&b'\n') { + i += 1; + } + } + b'\n' => line += 1, + // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are + // E2 80 A8 / E2 80 A9 in UTF-8. + 0xE2 if bytes.get(i + 1) == Some(&0x80) + && matches!(bytes.get(i + 2), Some(&(0xA8 | 0xA9))) => + { + line += 1; + i += 2; + } + _ => {} + } + i += 1; + } + line +} + +#[cfg(test)] +mod tests { + use super::line_number_at; + + #[test] + fn counts_typescript_line_breaks() { + assert_eq!(line_number_at("class A {}", 0), 1); + assert_eq!(line_number_at("a\nb\nclass", 4), 3); + // `\r\n` is one line break, a lone `\r` is another. + assert_eq!(line_number_at("a\r\nb\rclass", 6), 3); + // U+2028 and U+2029 end lines too. + let src = "a\u{2028}b\u{2029}class"; + assert_eq!(line_number_at(src, src.find("class").unwrap()), 3); + // Other non-ASCII characters do not. + let src = "é\u{2027}class"; + assert_eq!(line_number_at(src, src.find("class").unwrap()), 1); + } +} diff --git a/crates/oxc_angular_compiler/src/class_debug_info/mod.rs b/crates/oxc_angular_compiler/src/class_debug_info/mod.rs index 7b2c99005..68148772f 100644 --- a/crates/oxc_angular_compiler/src/class_debug_info/mod.rs +++ b/crates/oxc_angular_compiler/src/class_debug_info/mod.rs @@ -18,4 +18,4 @@ mod compiler; mod metadata; pub use compiler::compile_class_debug_info; -pub use metadata::R3ClassDebugInfo; +pub use metadata::{R3ClassDebugInfo, line_number_at}; diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs index 6c33bbbad..e4ae4d95f 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -15,12 +15,12 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use oxc_allocator::Allocator; use oxc_ast::ast::{ BindingPattern, Class, Declaration, ExportDefaultDeclarationKind, Expression, - ObjectPropertyKind, Statement, TSNamespaceDeclarationBody, + ObjectPropertyKind, Program, Statement, TSNamespaceDeclarationBody, }; use oxc_parser::Parser; use oxc_resolver::{ @@ -29,7 +29,7 @@ use oxc_resolver::{ use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; -use crate::directive::ImportValueResolver; +use crate::directive::{ImportValueResolver, StringConsts}; /// Result of analyzing a file's exports #[derive(Debug, Clone)] @@ -48,6 +48,10 @@ pub struct ExportInfo { /// hit a file already in `analyzing`, making the result depend on which /// file happened to be read first. pub(crate) value_local: Option, + /// A top-level `export class` or `export function`: the evaluator reads + /// it as a reference, which `StaticValue` can't hold, so + /// [`Inner::export_value_at`] answers `None` without evaluating it. + pub(crate) never_static: bool, } /// Bounds a re-export chain (`export { X } from ...`, `export *`) followed @@ -74,9 +78,9 @@ struct Inner { cache: RefCell>>, /// Files currently being analyzed (for circular import detection) analyzing: RefCell>, - /// Source text of analyzed files, kept so `eval_export` can re-parse - /// on demand. Only populated when `values_enabled`. - sources: RefCell>, + /// Files parsed for `eval_export` and `class_selector`, so each is + /// parsed once, on first use. `analyze_file` doesn't keep its parse. + parsed: RefCell>>, /// Memoized per-export value evaluations: `(file, local_name)` -> value. /// `None` means evaluated-but-opaque. value_memo: RefCell>>, @@ -90,12 +94,59 @@ struct Inner { evaluating_values: RefCell>, /// Files read to resolve exported values, so build tools can watch them. value_dependencies: RefCell>, - /// Whether exported-value resolution (`resolve_imported_values`) is on. - /// When false, `analyze_file` doesn't retain source text. - values_enabled: bool, - /// `Rc` handle to this state, for resolvers built while analyzing a file - /// (`eval_export` only sees `&self`). - this: std::rc::Weak, + /// Handle to this state, for the resolvers of the files in `parsed` + /// (`parse_file` only sees `&self`). + this: Weak, +} + +/// A file parsed once per analyzer: its program, and the consts the +/// evaluator reads it through, which resolve its imports with a +/// [`ParsedFileResolver`]. +struct ParsedFile { + // `consts` and `program` borrow `allocator`, which also holds the source + // text. They're declared first so they drop first; the allocator is boxed + // so it stays put when `ParsedFile` moves. + consts: StringConsts<'static>, + program: &'static Program<'static>, + allocator: Box, +} + +impl ParsedFile { + /// Run `f` on the file's allocator, program and consts. `f` works for + /// any lifetime, so nothing borrowed from them outlives `self`. + fn with( + &self, + f: impl for<'a> FnOnce(&'a Allocator, &'a Program<'a>, &'a StringConsts<'a>) -> R, + ) -> R { + // SAFETY: `program` and `consts` only borrow `self.allocator`, which + // lives as long as `self`, so they're valid for the borrow of `self`. + // `StringConsts` is invariant over its lifetime (its caches are + // `RefCell`s), hence the cast; `f` can only store data borrowed for + // that same lifetime in them, i.e. from the allocator. + let consts = unsafe { &*std::ptr::from_ref(&self.consts).cast::>() }; + f(&self.allocator, self.program, consts) + } +} + +/// The [`ImportValueResolver`] of a file in [`Inner::parsed`]: a +/// [`CrossFileResolver`] holding a `Weak` handle, so the cache doesn't keep +/// the analyzer alive. +struct ParsedFileResolver { + inner: Weak, + from_file: PathBuf, +} + +impl ImportValueResolver for ParsedFileResolver { + fn resolve(&self, module: &str, name: &str) -> Option { + self.inner.upgrade()?.resolve_export_value(module, name, &self.from_file) + } + + fn exports( + &self, + module: &str, + ) -> Option)>> { + self.inner.upgrade()?.export_values_of(module, &self.from_file) + } } /// An [`ImportValueResolver`] bound to the file whose imports it resolves: @@ -119,22 +170,17 @@ impl ImportValueResolver for CrossFileResolver { } } -/// The selector of the class `local` declares in `source` (the text of -/// `file_path`; `"default"` for `export default class`) and exports as -/// `exported`, when it is a standalone `@Component` or `@Directive` whose -/// `selector` and `standalone` are literals. -fn standalone_class_selector( - file_path: &str, - source: &str, +/// The selector of the class `local` declares in `program` (`"default"` for +/// `export default class`) and exports as `exported`, when it is a +/// standalone `@Component` or `@Directive` whose `selector` and `standalone` +/// are literals. `consts` are the program's. +fn standalone_class_selector<'a>( + program: &'a Program<'a>, + consts: &StringConsts<'a>, exported: &str, local: &str, implicit_standalone: bool, ) -> Option { - let allocator = Allocator::default(); - let source_type = SourceType::from_path(file_path).unwrap_or_default(); - let parser_ret = Parser::new(&allocator, source, source_type).parse(); - let program = &parser_ret.program; - // A plain `class Local` must be exported as `exported` by a local // `export { Local as exported }`: the export map also records namespace // members, which can share the name of a top-level class the file doesn't @@ -165,9 +211,8 @@ fn standalone_class_selector( _ => None, })?; - let consts = crate::directive::collect_string_consts(&allocator, program); let decorator = ["Component", "Directive"].iter().find_map(|name| { - crate::directive::find_angular_class_decorator(&class.decorators, name, &consts) + crate::directive::find_angular_class_decorator(&class.decorators, name, consts) })?; let Expression::CallExpression(call) = &decorator.expression else { return None }; let Some(Expression::ObjectExpression(object)) = @@ -200,9 +245,9 @@ impl CrossFileAnalyzer { /// /// * `base_dir` - The base directory for module resolution /// * `tsconfig_path` - Optional path to tsconfig.json for path aliases - /// * `values_enabled` - Enable exported-value resolution (the + /// * `_values_enabled` - Unused: files are parsed on demand either way. Was: enable exported-value resolution (the /// `resolve_imported_values` transform option) - pub fn new(_base_dir: &Path, tsconfig_path: Option<&Path>, values_enabled: bool) -> Self { + pub fn new(_base_dir: &Path, tsconfig_path: Option<&Path>, _values_enabled: bool) -> Self { let options = ResolveOptions { extensions: vec![".ts".into(), ".tsx".into(), ".js".into(), ".jsx".into()], tsconfig: tsconfig_path.map(|p| { @@ -219,12 +264,11 @@ impl CrossFileAnalyzer { resolver: Resolver::new(options), cache: RefCell::new(FxHashMap::default()), analyzing: RefCell::new(FxHashSet::default()), - sources: RefCell::new(FxHashMap::default()), + parsed: RefCell::new(FxHashMap::default()), value_memo: RefCell::new(FxHashMap::default()), selector_memo: RefCell::new(FxHashMap::default()), evaluating_values: RefCell::new(FxHashSet::default()), value_dependencies: RefCell::new(FxHashSet::default()), - values_enabled, this: this.clone(), }), } @@ -495,7 +539,7 @@ impl Inner { } /// [`standalone_class_selector`] memoized per `(file, exported, local)`, - /// reading the source `analyze_file` kept instead of the file again. + /// reading the file through [`Self::parsed_file`]. fn class_selector( &self, file_path: &str, @@ -508,16 +552,10 @@ impl Inner { if let Some(cached) = self.selector_memo.borrow().get(&key) { return cached.clone(); } - // `sources` is populated when `values_enabled`; read the file - // directly as a fallback, like `eval_export_uncached`. - let source = self - .sources - .borrow() - .get(file_path) - .cloned() - .or_else(|| std::fs::read_to_string(file_path).ok()); - let selector = source.and_then(|source| { - standalone_class_selector(file_path, &source, exported, local, implicit_standalone) + let selector = self.parsed_file(file_path).and_then(|file| { + file.with(|_, program, consts| { + standalone_class_selector(program, consts, exported, local, implicit_standalone) + }) }); self.selector_memo.borrow_mut().insert(key, selector.clone()); selector @@ -650,6 +688,9 @@ impl Inner { // A local export: evaluate its binding lazily. `local` is `"default"` // for `export default `. + if export_info.never_static { + return None; + } self.eval_export(file_path, &export_info.value_local?) } @@ -672,39 +713,55 @@ impl Inner { value } - /// [`Self::eval_export`] without the memo: re-parse the cached source and - /// evaluate the named binding. + /// [`Self::eval_export`] without the memo: evaluate the named binding in + /// the parsed file. fn eval_export_uncached( &self, file_path: &str, local: &str, ) -> Option { - // `sources` is populated by `analyze_file` when `values_enabled`; - // read the file directly as a fallback so behavior doesn't depend on - // which path discovered it first. - let source = self - .sources - .borrow() - .get(file_path) - .cloned() - .or_else(|| std::fs::read_to_string(file_path).ok())?; - - let allocator = Allocator::default(); - let source_type = SourceType::from_path(file_path).unwrap_or_default(); - let parser_ret = Parser::new(&allocator, &source, source_type).parse(); - let Some(inner) = self.this.upgrade() else { return None }; - let consts = - crate::directive::collect_string_consts(&allocator, &parser_ret.program).with_resolver( - Rc::new(CrossFileResolver { inner, from_file: Path::new(file_path).to_path_buf() }), - ); - let evaluator = crate::directive::Evaluator::new(&consts); + let file = self.parsed_file(file_path)?; + file.with(|allocator, program, consts| { + let evaluator = crate::directive::Evaluator::new(consts); + if local == "default" { + return self + .find_default_expr(&program.body) + .and_then(|expr| evaluator.evaluate(expr).as_static()); + } + evaluator.evaluate_name(allocator.alloc_str(local)).as_static() + }) + } - if local == "default" { - return self - .find_default_expr(&parser_ret.program.body) - .and_then(|expr| evaluator.evaluate(expr).as_static()); + /// `file_path` parsed, from [`Self::parsed`], or read and parsed now. + /// `None` when the file can't be read. + fn parsed_file(&self, file_path: &str) -> Option> { + if let Some(file) = self.parsed.borrow().get(file_path) { + return Some(Rc::clone(file)); } - evaluator.evaluate_name(allocator.alloc_str(local)).as_static() + let source = std::fs::read_to_string(file_path).ok()?; + let file = Rc::new(self.parse_file(file_path, &source)); + self.parsed.borrow_mut().insert(file_path.to_string(), Rc::clone(&file)); + Some(file) + } + + /// Parse `source`, the text of `file_path`, with consts whose imports + /// resolve from `file_path`. + fn parse_file(&self, file_path: &str, source: &str) -> ParsedFile { + let allocator = Box::new(Allocator::default()); + // SAFETY: the boxed allocator outlives the program and consts that + // borrow it (see `ParsedFile`), and `ParsedFile::with` only lends + // them for the borrow of the `ParsedFile`. + let arena: &'static Allocator = unsafe { &*std::ptr::from_ref::(&*allocator) }; + let source = arena.alloc_str(source); + let source_type = SourceType::from_path(file_path).unwrap_or_default(); + let program = arena.alloc(Parser::new(arena, source, source_type).parse().program); + let resolver = ParsedFileResolver { + inner: Weak::clone(&self.this), + from_file: Path::new(file_path).to_path_buf(), + }; + let consts = crate::directive::collect_string_consts(arena, program) + .with_resolver(Rc::new(resolver)); + ParsedFile { consts, program, allocator } } /// The initializer expression of `export default `, searching the @@ -863,6 +920,7 @@ impl Inner { is_type_only: info.is_type_only, re_export_source: Some((resolved, export_name.to_string())), value_local: None, + never_static: false, }); } @@ -872,6 +930,7 @@ impl Inner { is_type_only: info.is_type_only, re_export_source: Some((resolved, export_name.to_string())), value_local: None, + never_static: false, }); } } @@ -970,30 +1029,70 @@ impl Inner { } }; - if self.values_enabled { - self.sources.borrow_mut().insert(file_path.to_string(), source.clone()); - } - + // Keep only the export map: the full parse (AST and consts) is built + // lazily by `parsed_file` for the few files that are evaluated or + // whose selector is read, so a wide `export *` barrel doesn't hold + // every analyzed file's AST until the analyzer is dropped. + let mut exports = FxHashMap::default(); let allocator = Allocator::default(); let source_type = SourceType::from_path(file_path).unwrap_or_default(); let parser_ret = Parser::new(&allocator, &source, source_type).parse(); - - let mut exports = FxHashMap::default(); - - for stmt in &parser_ret.program.body { - self.analyze_statement(stmt, &mut exports); - } + self.analyze_program(&parser_ret.program, &mut exports); self.cache.borrow_mut().insert(file_path.to_string(), exports); self.analyzing.borrow_mut().remove(file_path); } - /// Analyze a statement for export information. - fn analyze_statement(&self, stmt: &Statement, exports: &mut FxHashMap) { + /// Collect `program`'s exports into `exports`. + fn analyze_program(&self, program: &Program, exports: &mut FxHashMap) { + for stmt in &program.body { + self.analyze_statement(stmt, exports, true); + } + // A top-level variable or enum shadows a class or function of the + // same name for the evaluator (a duplicate declaration TypeScript + // rejects): evaluate those exports as before. + if !exports.values().any(|info| info.never_static) { + return; + } + for stmt in &program.body { + let decl = match stmt { + Statement::ExportDeclaration(decl) => &decl.declaration, + _ => match stmt.as_declaration() { + Some(decl) => decl, + None => continue, + }, + }; + let mut shadow = |name: &str| { + if let Some(info) = exports.get_mut(name) { + info.never_static = false; + } + }; + match decl { + Declaration::VariableDeclaration(vars) => { + for var in &vars.declarations { + for id in var.id.get_binding_identifiers() { + shadow(&id.name); + } + } + } + Declaration::TSEnumDeclaration(e) => shadow(&e.id.name), + _ => {} + } + } + } + + /// Analyze a statement for export information. `top_level` is whether + /// `stmt` is in the program body rather than a namespace or module block. + fn analyze_statement( + &self, + stmt: &Statement, + exports: &mut FxHashMap, + top_level: bool, + ) { match stmt { // export class/function/const/interface/type Foo { ... } Statement::ExportDeclaration(decl) => { - self.analyze_declaration(&decl.declaration, exports); + self.analyze_declaration(&decl.declaration, exports, top_level); } // Local named export: export { X, Y } / export type { X } Statement::ExportNamedDeclaration(decl) => { @@ -1006,6 +1105,7 @@ impl Inner { is_type_only: true, re_export_source: None, value_local: None, + never_static: false, }, ); } @@ -1022,7 +1122,12 @@ impl Inner { let value_local = (!is_type_only).then(|| spec.local.name().to_string()); exports.insert( name, - ExportInfo { is_type_only, re_export_source: None, value_local }, + ExportInfo { + is_type_only, + re_export_source: None, + value_local, + never_static: false, + }, ); } } @@ -1040,6 +1145,7 @@ impl Inner { is_type_only: true, re_export_source: None, value_local: None, + never_static: false, }, ); } @@ -1058,6 +1164,7 @@ impl Inner { is_type_only: spec.export_kind.is_type(), re_export_source: source.map(|source| (source.to_string(), local_name)), value_local: None, + never_static: false, }, ); } @@ -1075,6 +1182,7 @@ impl Inner { is_type_only, re_export_source: None, value_local: (!is_type_only).then(|| "default".to_string()), + never_static: false, }, ); } @@ -1090,6 +1198,7 @@ impl Inner { is_type_only: false, // Namespace re-export - assume value re_export_source: Some((source_module, "*".to_string())), value_local: None, + never_static: false, }, ); } else { @@ -1102,6 +1211,7 @@ impl Inner { is_type_only: false, re_export_source: Some((source_module, "*".to_string())), value_local: None, + never_static: false, }, ); } @@ -1110,7 +1220,7 @@ impl Inner { Statement::TSExternalModuleDeclaration(module_decl) => { if let Some(block) = &module_decl.body { for inner_stmt in &block.body { - self.analyze_statement(inner_stmt, exports); + self.analyze_statement(inner_stmt, exports, false); } } } @@ -1121,7 +1231,7 @@ impl Inner { // declare global { ... } Statement::TSGlobalDeclaration(global_decl) => { for inner_stmt in &global_decl.body.body { - self.analyze_statement(inner_stmt, exports); + self.analyze_statement(inner_stmt, exports, false); } } _ => {} @@ -1137,7 +1247,7 @@ impl Inner { match body { TSNamespaceDeclarationBody::TSModuleBlock(block) => { for inner_stmt in &block.body { - self.analyze_statement(inner_stmt, exports); + self.analyze_statement(inner_stmt, exports, false); } } TSNamespaceDeclarationBody::TSNamespaceDeclaration(nested) => { @@ -1146,15 +1256,26 @@ impl Inner { } } - /// Analyze a declaration and add export information. - fn analyze_declaration(&self, decl: &Declaration, exports: &mut FxHashMap) { + /// Analyze a declaration and add export information. `top_level` is + /// whether it's declared in the program body (see + /// [`ExportInfo::never_static`]). + fn analyze_declaration( + &self, + decl: &Declaration, + exports: &mut FxHashMap, + top_level: bool, + ) { // A runtime export binds the declared name; its value is evaluated // lazily by `eval_export`. Type declarations have no value local. let value_info = |name: &str, is_type_only: bool| ExportInfo { is_type_only, re_export_source: None, value_local: (!is_type_only).then(|| name.to_string()), + never_static: false, }; + // A class or function evaluates to a reference to itself. + let reference_info = + |name: &str| ExportInfo { never_static: top_level, ..value_info(name, false) }; match decl { Declaration::TSInterfaceDeclaration(d) => { exports.insert(d.id.name.to_string(), value_info(&d.id.name, true)); @@ -1164,12 +1285,12 @@ impl Inner { } Declaration::ClassDeclaration(d) => { if let Some(id) = &d.id { - exports.insert(id.name.to_string(), value_info(&id.name, false)); + exports.insert(id.name.to_string(), reference_info(&id.name)); } } Declaration::FunctionDeclaration(d) => { if let Some(id) = &d.id { - exports.insert(id.name.to_string(), value_info(&id.name, false)); + exports.insert(id.name.to_string(), reference_info(&id.name)); } } Declaration::VariableDeclaration(d) => { @@ -1199,7 +1320,7 @@ impl Inner { /// Clear the analysis cache. pub fn clear_cache(&self) { self.cache.borrow_mut().clear(); - self.sources.borrow_mut().clear(); + self.parsed.borrow_mut().clear(); self.value_memo.borrow_mut().clear(); self.selector_memo.borrow_mut().clear(); } @@ -1840,8 +1961,8 @@ export { doSomething } from './utils'; #[test] fn test_value_elision_only_analyzer_resolves_on_demand() { - // `resolve_export_value` works without `values_enabled`: `sources` is - // empty and the file is re-read for evaluation. + // `resolve_export_value` works without `values_enabled`: files are + // parsed on demand. let dir = TempDir::new().unwrap(); create_test_file(dir.path(), "other.ts", "export const V = 'v';"); let main_file = dir.path().join("main.ts"); @@ -1922,7 +2043,7 @@ export { doSomething } from './utils'; #[test] fn test_standalone_directive_selector_is_memoized() { - // The selector is read from the source `analyze_file` kept and + // The selector is read from the file parsed on first use and // memoized, not re-read from disk on every lookup, until the caches // are cleared. The file is still reported as a dependency each time. let dir = TempDir::new().unwrap(); @@ -1944,4 +2065,98 @@ export { doSomething } from './utils'; analyzer.clear_cache(); assert_eq!(selector(&analyzer).as_deref(), Some("app-b")); } + + #[test] + fn test_value_file_parsed_once() { + // The first evaluation's parse serves every later one in the file: an + // edit made after it isn't seen. + let dir = TempDir::new().unwrap(); + let meta = + create_test_file(dir.path(), "meta.ts", "export const A = 'a';\nexport const B = 'b';"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./meta", "A"), + Some(StaticValue::String(s)) if s == "a" + )); + fs::write(&meta, "export const A = 'x';\nexport const B = 'y';").unwrap(); + assert!(matches!( + resolve(&analyzer, &main_file, "./meta", "B"), + Some(StaticValue::String(s)) if s == "b" + )); + assert_eq!(analyzer.inner.parsed.borrow().len(), 1); + } + + #[test] + fn test_value_analysis_does_not_keep_asts_of_barrel_files() { + // Analyzing a wide `export *` barrel keeps only export maps; just the + // file whose value is evaluated is parsed and kept. + let dir = TempDir::new().unwrap(); + let mut barrel = String::new(); + for i in 0..5 { + create_test_file( + dir.path(), + &format!("f{i}.ts"), + &format!("export const V{i} = 'v{i}';"), + ); + barrel.push_str(&format!("export * from './f{i}';\n")); + } + create_test_file(dir.path(), "barrel.ts", &barrel); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./barrel", "V4"), + Some(StaticValue::String(s)) if s == "v4" + )); + assert_eq!(analyzer.inner.parsed.borrow().len(), 1); + } + + #[test] + fn test_value_class_and_function_exports_are_opaque() { + // Top-level classes and functions resolve to `None` without being + // evaluated; the export map still gives their local for selectors. + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "decls.ts", + "export class C {}\nexport function f() { return 1; }\nexport const V = 'v';", + ); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(resolve(&analyzer, &main_file, "./decls", "C").is_none()); + assert!(resolve(&analyzer, &main_file, "./decls", "f").is_none()); + assert!(analyzer.inner.value_memo.borrow().is_empty()); + assert!(matches!( + resolve(&analyzer, &main_file, "./decls", "V"), + Some(StaticValue::String(s)) if s == "v" + )); + } + + #[test] + fn test_value_namespace_class_export_still_evaluates() { + // A namespace member's export entry evaluates the top-level name it + // shares (here a `const`): only top-level declarations skip + // evaluation, as do ones a top-level variable doesn't shadow. + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "ns.ts", + "export const Foo = 'x';\nexport namespace N { export class Foo {} }", + ); + create_test_file(dir.path(), "dup.ts", "const Bar = 'y';\nexport class Bar {}"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./ns", "Foo"), + Some(StaticValue::String(s)) if s == "x" + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./dup", "Bar"), + Some(StaticValue::String(s)) if s == "y" + )); + } } diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index d4ceea707..471bff66d 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -6,7 +6,7 @@ use oxc_allocator::{Allocator, Vec}; use oxc_ast::ast::{ Argument, ArrayExpressionElement, Class, ClassElement, Decorator, Expression, - MethodDefinitionKind, ObjectPropertyKind, PropertyKey, + MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, PropertyKey, }; use oxc_span::Span; use oxc_str::Ident; @@ -18,9 +18,8 @@ use super::metadata::{ }; use super::transform::ImportMap; use crate::directive::{ - HostKey, StringConsts, extract_host_bindings_in, extract_host_listeners_in, - extract_input_metadata_in, extract_output_metadata_in, merge_by_class_property, - parse_decorator_io, + HostKey, MemberHostBindings, StringConsts, evaluate_host_directives, extract_input_metadata_in, + extract_output_metadata_in, merge_by_class_property, merge_host_bindings, parse_decorator_io, }; use crate::output::oxc_converter::convert_oxc_expression; use crate::util::is_metadata_property; @@ -61,26 +60,16 @@ pub fn extract_component_metadata<'a>( let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; - // Find Angular's @Component decorator - let component_decorator = find_component_decorator(&class.decorators, consts)?; - - // Get the decorator call arguments - let call_expr = match &component_decorator.expression { - Expression::CallExpression(call) => call, - _ => return None, - }; - - // Get the first argument (the config object) - let config_arg = call_expr.arguments.first()?; - let config_obj = match config_arg { - Argument::ObjectExpression(obj) => obj, - _ => return None, - }; + let config_obj = component_config(class, consts)?; // Create metadata with defaults (standalone uses implicit_standalone when not explicitly set) let mut metadata = ComponentMetadata::new(allocator, class_name, class_span, implicit_standalone); + // Whether `imports` and `deferredImports` are provably empty; a spread or + // accessor this loop skips could add them. + let mut imports_provably_empty = true; + // Parse each property in the config object for prop in &config_obj.properties { if let ObjectPropertyKind::ObjectProperty(prop) = prop @@ -113,7 +102,7 @@ pub fn extract_component_metadata<'a>( // for @Component.template field" in local mode, // "template must be a string" otherwise) — pre-existing. metadata.template = - crate::directive::extract_string_value(allocator, &prop.value, consts); + crate::directive::evaluate_string_value(allocator, &prop.value, consts); // The text between the quotes or backticks. metadata.template_span = match &prop.value { Expression::StringLiteral(lit) => Some(lit.span), @@ -125,8 +114,12 @@ pub fn extract_component_metadata<'a>( .map(|span| Span::new(span.start + 1, span.end - 1)); } "templateUrl" => { + // Evaluated like ngtsc's `parseTemplateDeclaration`, so a + // URL imported from another file resolves when the consts + // carry the cross-file resolver. The resource extractor + // (`extract_component_resource_urls`) reads it the same way. metadata.template_url = - crate::directive::extract_string_value(allocator, &prop.value, consts); + crate::directive::evaluate_string_value(allocator, &prop.value, consts); } "styles" => { // ngtsc's `reflectObjectLiteral` drops computed keys and @@ -157,13 +150,7 @@ pub fn extract_component_metadata<'a>( } } "styleUrls" | "styleUrl" => { - if let Some(urls) = extract_string_array(allocator, &prop.value, consts) { - metadata.style_urls = urls; - } else if let Some(url) = - crate::directive::extract_string_value(allocator, &prop.value, consts) - { - metadata.style_urls.push(url); - } + read_style_urls(allocator, &prop.value, consts, &mut metadata.style_urls); } "standalone" => { // Only override the implicit value if an explicit boolean is provided @@ -187,6 +174,7 @@ pub fn extract_component_metadata<'a>( } } "imports" => { + imports_provably_empty &= is_empty_array_literal(&prop.value); // For standalone components, we need: // 1. The identifier list for local analysis metadata.imports = extract_identifier_array(allocator, &prop.value); @@ -195,6 +183,7 @@ pub fn extract_component_metadata<'a>( convert_oxc_expression(allocator, &prop.value, source_text); } "deferredImports" => { + imports_provably_empty &= is_empty_array_literal(&prop.value); // Symbols explicitly opted into lazy loading via `@defer`. // Mirrors Angular's `@Component.deferredImports` field; in // local compilation this is the only source of deferrable @@ -258,51 +247,23 @@ pub fn extract_component_metadata<'a>( // Unknown property - ignore } } + } else { + imports_provably_empty = false; } } + metadata.imports_provably_empty = imports_provably_empty; - // Extract host bindings and listeners from @HostBinding/@HostListener decorators on class members - // These are merged with any host metadata from the @Component({ host: {} }) property - // - // Divergence from ngtsc (pre-existing merge structure): upstream assigns - // member entries into `bindings.properties[name]` / `bindings.listeners` - // [eventName], overwriting same-named `host:` entries so one binding per - // key remains. Here they are appended instead, so a key declared in both - // emits two bindings — both listeners fire, and both property ops emit - // (the member's, applied last, still wins like upstream). - let host_bindings = extract_host_bindings_in(allocator, class, Some(consts)); - let host_listeners = extract_host_listeners_in(allocator, class, Some(consts)); - - if !host_bindings.is_empty() || !host_listeners.is_empty() { + // Merge @HostBinding/@HostListener members into the `host:` bindings the + // way ngtsc's `extractHostBindings` does (shared with @Directive). + let members = MemberHostBindings::of(allocator, class, Some(consts)); + if !members.is_empty() || metadata.host.is_some() { let host = metadata.host.get_or_insert_with(|| HostMetadata::new(allocator)); - - // Add @HostBinding properties - // Wrap with brackets: "class.active" -> "[class.active]" - for (host_prop, class_prop) in host_bindings { - let wrapped_key = - Ident::from(allocator.alloc_str(&format!("[{}]", host_prop.as_str()))); - host.properties.push((wrapped_key, class_prop)); - } - - // Add @HostListener events - // Wrap event name with parentheses and build method expression with args - // Reference: Angular's shared.ts:713 - `bindings.listeners[eventName] = \`${member.name}(${args.join(',')})\`` - for (event_name, method_name, args) in host_listeners { - // Wrap event name: "click" -> "(click)" - let wrapped_key = - Ident::from(allocator.alloc_str(&format!("({})", event_name.as_str()))); - - // Build method expression with args: "handleClick" + ["$event"] -> "handleClick($event)" - let method_expr = if args.is_empty() { - Ident::from(allocator.alloc_str(&format!("{}()", method_name.as_str()))) - } else { - let args_str: String = - args.iter().map(|a| a.as_str()).collect::>().join(","); - Ident::from(allocator.alloc_str(&format!("{}({})", method_name.as_str(), args_str))) - }; - - host.listeners.push((wrapped_key, method_expr)); - } + merge_host_bindings( + &mut host.properties, + &mut host.listeners, + &mut host.attributes, + members, + ); } // Detect if the component extends another class @@ -423,6 +384,11 @@ fn get_property_key_name<'a>( } } +/// Whether `expr` is the array literal `[]`. +fn is_empty_array_literal(expr: &Expression<'_>) -> bool { + matches!(expr, Expression::ArrayExpression(array) if array.elements.is_empty()) +} + /// Extract a boolean value from an expression. fn extract_boolean_value(expr: &Expression<'_>) -> Option { match expr { @@ -430,33 +396,71 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { _ => None, } } -/// Extract an array of strings from an expression. -/// -/// `${...}` interpolations inside individual template-literal elements are -/// folded against `consts` (e.g. `` styles: [`:host { color: ${COLOR}; }`] ``) -/// to match Angular's partial evaluator. Identifier elements (`[STYLE_CONST]`) -/// are likewise resolved. -fn extract_string_array<'a>( - allocator: &'a Allocator, - expr: &Expression<'a>, - consts: &StringConsts<'a>, -) -> Option>> { - let Expression::ArrayExpression(arr) = expr else { +/// The `{...}` config object of `class`'s `@Component(...)`, or `None` when +/// the class has no `@Component` call with an object literal argument. +fn component_config<'a>( + class: &'a Class<'a>, + consts: &StringConsts<'_>, +) -> Option<&'a ObjectExpression<'a>> { + let component_decorator = find_component_decorator(&class.decorators, consts)?; + let Expression::CallExpression(call_expr) = &component_decorator.expression else { return None; }; + match call_expr.arguments.first()? { + Argument::ObjectExpression(obj) => Some(obj), + _ => None, + } +} - let mut result = Vec::new_in(&allocator); - for element in &arr.elements { - // ArrayExpressionElement variants that can hold a value expression all - // carry one — funnel them through `as_expression()` and let the shared - // resolver decide which shapes fold. - let Some(elem_expr) = element.as_expression() else { continue }; - if let Some(value) = crate::directive::extract_string_value(allocator, elem_expr, consts) { - result.push(value); - } +/// One `styleUrl` / `styleUrls` property: an array literal replaces the URLs +/// read so far; any other value adds to them. +fn read_style_urls<'a>( + allocator: &'a Allocator, + value: &'a Expression<'a>, + consts: &StringConsts<'a>, + urls: &mut Vec<'a, Ident<'a>>, +) { + if matches!(value, Expression::ArrayExpression(_)) { + urls.clear(); } + crate::directive::evaluate_style_urls(allocator, value, consts, urls); +} - Some(result) +/// The `templateUrl` and `styleUrl(s)` of `class`'s `@Component`, read exactly +/// as [`extract_component_metadata`] reads them, and `None` exactly when it +/// gives `None`. Only those properties are evaluated, so the import value +/// resolver in `consts` (if any) reads another file only when one of them +/// names an imported binding — never for `host`, `styles`, inputs or deps. +pub fn extract_component_resource_urls_of<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + consts: &StringConsts<'a>, +) -> Option<(Option>, Vec<'a, Ident<'a>>)> { + class.id.as_ref()?; + let config_obj = component_config(class, consts)?; + let mut template_url = None; + let mut style_urls = Vec::new_in(&allocator); + for prop in &config_obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop + && is_metadata_property(prop) + { + if matches!(&prop.key, PropertyKey::StringLiteral(lit) if lit.value.has_lone_surrogate()) + { + continue; + } + match get_property_key_name(&prop.key, consts)?.as_str() { + "templateUrl" => { + template_url = + crate::directive::evaluate_string_value(allocator, &prop.value, consts); + } + "styleUrls" | "styleUrl" => { + read_style_urls(allocator, &prop.value, consts, &mut style_urls); + } + _ => {} + } + } + } + Some((template_url, style_urls)) } /// Extract an array of identifiers (for imports). @@ -559,282 +563,37 @@ fn extract_host_metadata<'a>( Some(host) } -/// Extract host directives from a hostDirectives array expression. -/// -/// Divergence from ngtsc's `extractHostDirectives`: upstream evaluates the -/// expression with the partial evaluator (a const or spread array works, a -/// non-array is an error). Here only a literal `ArrayExpression` is read and -/// anything else yields no host directives, with no diagnostic. -/// -/// Handles the following patterns: -/// - Simple identifier: `hostDirectives: [TooltipDirective]` -/// - Object with directive: `hostDirectives: [{ directive: ColorDirective }]` -/// - Object with mappings: `hostDirectives: [{ directive: D, inputs: ['color', 'bgColor'], outputs: ['changed'] }]` -/// - Forward ref: `hostDirectives: [forwardRef(() => MyDirective)]` -/// -/// Reference: packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts:1873-1985 +/// Extract host directives from a `hostDirectives` expression, read through +/// the evaluator like ngtsc's `extractHostDirectives` (see +/// [`crate::directive::evaluate_host_directives`]). fn extract_host_directives<'a>( allocator: &'a Allocator, - expr: &Expression<'a>, + expr: &'a Expression<'a>, import_map: &ImportMap<'a>, consts: &StringConsts<'a>, ) -> Vec<'a, HostDirectiveMetadata<'a>> { - let mut result = Vec::new_in(&allocator); - - let Expression::ArrayExpression(arr) = expr else { - return result; - }; - - for element in &arr.elements { - if let Some(meta) = extract_single_host_directive(allocator, element, import_map, consts) { - result.push(meta); + let entries = evaluate_host_directives(allocator, expr, consts); + let mut result = Vec::with_capacity_in(entries.len(), &allocator); + 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) { + meta.source_module = Some(import_info.source_module); } - } - - result -} - -/// Extract a single host directive from an array element. -/// -/// Handles: -/// - Identifier: `TooltipDirective` -/// - Object: `{ directive: ColorDirective, inputs: [...], outputs: [...] }` -/// - ForwardRef call: `forwardRef(() => MyDirective)` -fn extract_single_host_directive<'a>( - allocator: &'a Allocator, - element: &ArrayExpressionElement<'a>, - import_map: &ImportMap<'a>, - consts: &StringConsts<'a>, -) -> Option> { - match element { - // Simple identifier: TooltipDirective - ArrayExpressionElement::Identifier(id) => { - let name: Ident<'a> = id.name.clone().into(); - let mut meta = HostDirectiveMetadata::new(allocator, name.clone()); - // Look up the source module from the import map - if let Some(import_info) = import_map.get(&name) { - meta.source_module = Some(import_info.source_module.clone()); - } - Some(meta) + meta.is_forward_reference = entry.is_forward_reference; + meta.has_mapping_keys = has_mapping_keys; + if let Some(inputs) = entry.inputs { + meta.inputs = inputs; } - - // Object expression: { directive: ColorDirective, inputs: [...], outputs: [...] } - ArrayExpressionElement::ObjectExpression(obj) => { - let mut directive_name: Option> = None; - let mut inputs = Vec::new_in(&allocator); - let mut outputs = Vec::new_in(&allocator); - let mut is_forward_reference = false; - - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop - && is_metadata_property(prop) - { - let Some(key_name) = get_property_key_name(&prop.key, consts) else { - continue; - }; - - match key_name.as_str() { - "directive" => { - // Extract directive class name - // Can be identifier or forwardRef call - let (name, is_forward_ref) = extract_directive_reference(&prop.value); - directive_name = name; - is_forward_reference = is_forward_ref; - } - "inputs" => { - // Extract input mappings - inputs = extract_io_mappings(allocator, &prop.value); - } - "outputs" => { - // Extract output mappings - outputs = extract_io_mappings(allocator, &prop.value); - } - _ => { - // Unknown property - ignore - } - } - } - } - - directive_name.map(|name| { - let mut meta = HostDirectiveMetadata::new(allocator, name.clone()); - // Look up the source module from the import map - if let Some(import_info) = import_map.get(&name) { - meta.source_module = Some(import_info.source_module.clone()); - } - meta.inputs = inputs; - meta.outputs = outputs; - meta.is_forward_reference = is_forward_reference; - meta - }) + if let Some(outputs) = entry.outputs { + meta.outputs = outputs; } - - // ForwardRef call: forwardRef(() => MyDirective) - ArrayExpressionElement::CallExpression(call) => { - if is_forward_ref_call(&call.callee) { - if let Some(name) = extract_forward_ref_directive_name(call.arguments.first()) { - let mut meta = HostDirectiveMetadata::new(allocator, name.clone()); - // Look up the source module from the import map - if let Some(import_info) = import_map.get(&name) { - meta.source_module = Some(import_info.source_module.clone()); - } - meta.is_forward_reference = true; - return Some(meta); - } - } - None - } - - _ => None, - } -} - -/// Extract a directive reference from an expression. -/// -/// Returns the directive class name and whether it's a forward reference. -fn extract_directive_reference<'a>(expr: &Expression<'a>) -> (Option>, bool) { - match expr { - // Simple identifier: ColorDirective - Expression::Identifier(id) => (Some(id.name.clone().into()), false), - - // ForwardRef call: forwardRef(() => ColorDirective) - Expression::CallExpression(call) => { - if is_forward_ref_call(&call.callee) { - (extract_forward_ref_directive_name(call.arguments.first()), true) - } else { - (None, false) - } - } - - _ => (None, false), - } -} - -/// Check if a callee is a forwardRef call. -fn is_forward_ref_call(callee: &Expression<'_>) -> bool { - match callee { - Expression::Identifier(id) => id.name == "forwardRef", - _ => false, - } -} - -/// Extract the directive name from a forwardRef argument. -/// -/// Handles: `forwardRef(() => MyDirective)` -fn extract_forward_ref_directive_name<'a>(arg: Option<&Argument<'a>>) -> Option> { - let arg = arg?; - match arg { - // forwardRef(() => MyDirective) - Argument::ArrowFunctionExpression(arrow) => { - // Expression body: () => MyDirective - if let Some(Expression::Identifier(id)) = arrow.get_expression() { - return Some(id.name.clone().into()); - } - // Block body with single ExpressionStatement (parity with pre-0.144) - if let Some(body) = arrow.get_function_body() { - if let Some(oxc_ast::ast::Statement::ExpressionStatement(stmt)) = - body.statements.first() - { - if let Expression::Identifier(id) = &stmt.expression { - return Some(id.name.clone().into()); - } - } - } - None - } - _ => None, + result.push(meta); } -} - -/// Extract input/output mappings from an array expression. -/// -/// Handles two formats: -/// - Simple string: `["color"]` - same public and internal name -/// - Mapping string: `["color: bgColor"]` - public name mapped to internal name -/// -/// Returns Vec of (publicName, internalName) pairs. -/// -/// Divergence: ngtsc evaluates the array with the partial evaluator, so a -/// const or a spread works; here only a literal `ArrayExpression` is read. -fn extract_io_mappings<'a>( - allocator: &'a Allocator, - expr: &Expression<'a>, -) -> Vec<'a, (Ident<'a>, Ident<'a>)> { - let mut result = Vec::new_in(&allocator); - - let Expression::ArrayExpression(arr) = expr else { - return result; - }; - - for element in &arr.elements { - if let Some((public_name, internal_name)) = parse_mapping_element(allocator, element) { - result.push((public_name, internal_name)); - } - } - result } -/// Parse a single mapping element from an input/output array. -/// -/// Handles: -/// - Simple string: `"color"` -> ("color", "color") -/// - Mapping string: `"color: bgColor"` -> ("bgColor", "color") -/// - Tuple array: `["color", "bgColor"]` -> ("bgColor", "color") -fn parse_mapping_element<'a>( - allocator: &'a Allocator, - element: &ArrayExpressionElement<'a>, -) -> Option<(Ident<'a>, Ident<'a>)> { - match element { - // Simple string: "color" - same public and internal name - ArrayExpressionElement::StringLiteral(lit) => { - let value = lit.value.as_str()?; - - // Check for mapping format: "internalName: publicName" - if let Some(colon_pos) = value.find(':') { - let internal_name = value[..colon_pos].trim(); - let public_name = value[colon_pos + 1..].trim(); - Some(( - Ident::from(allocator.alloc_str(public_name)), - Ident::from(allocator.alloc_str(internal_name)), - )) - } else { - // Same name for both - Some((Ident::from(value), Ident::from(value))) - } - } - - // Tuple array: ["internalName", "publicName"] - ArrayExpressionElement::ArrayExpression(arr) => { - let elements = &arr.elements; - if elements.len() >= 2 { - let internal = match elements.first() { - Some(ArrayExpressionElement::StringLiteral(lit)) => { - lit.value.as_str().map(Ident::from) - } - _ => None, - }; - let public = match elements.get(1) { - Some(ArrayExpressionElement::StringLiteral(lit)) => { - lit.value.as_str().map(Ident::from) - } - _ => None, - }; - if let (Some(internal_name), Some(public_name)) = (internal, public) { - return Some((public_name, internal_name)); - } - } - None - } - - _ => None, - } -} - -// ============================================================================= -// Constructor Dependency Extraction -// ============================================================================= - /// Check if a class has an `ngOnChanges` method. /// /// This is used to determine if the component implements the `OnChanges` interface @@ -2238,7 +1997,7 @@ mod tests { #[test] fn test_extract_host_directives_forward_ref() { let code = r#" - import {Component} from '@angular/core'; + import {Component, forwardRef} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2256,7 +2015,7 @@ mod tests { #[test] fn test_extract_host_directives_forward_ref_in_object() { let code = r#" - import {Component} from '@angular/core'; + import {Component, forwardRef} from '@angular/core'; @Component({ selector: 'app-test', template: '', diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index 73a988b6f..f9d53d259 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -275,6 +275,57 @@ pub(super) fn implicitly_deferred_imports<'a>( .collect() } +/// Whether any element or template anywhere in the template (`@defer` blocks +/// included) matches one of `selectors`, as ngc's `R3TargetBinder` matches a +/// scope's directives. A selectorless component or directive counts as a +/// match, since nothing here can tell what it resolves to. +pub(super) fn template_may_match_selectors( + nodes: &[R3Node<'_>], + selectors: &[CssSelector], +) -> bool { + struct Matcher<'s> { + selectors: &'s [CssSelector], + matched: bool, + } + + impl Matcher<'_> { + fn record(&mut self, element: &CssSelector) { + self.matched |= self.selectors.iter().any(|selector| selector.matches(element)); + } + } + + impl<'a> R3Visitor<'a> for Matcher<'_> { + fn visit_element(&mut self, element: &R3Element<'a>) { + self.matched |= !element.directives.is_empty(); + self.record(&element_selector( + &element.name, + &element.attributes, + &element.inputs, + &element.outputs, + )); + self.visit_element_children(element); + } + + fn visit_template(&mut self, template: &R3Template<'a>) { + self.matched |= !template.directives.is_empty(); + self.record(&template_selector(template)); + self.visit_template_children(template); + } + + fn visit_component(&mut self, _component: &R3Component<'a>) { + self.matched = true; + } + + fn visit_directive(&mut self, _directive: &R3Directive<'a>) { + self.matched = true; + } + } + + let mut matcher = Matcher { selectors, matched: false }; + visit_all(&mut matcher, nodes); + matcher.matched +} + /// An element as Angular matches directives against it /// (`createCssSelectorFromNode` and `getAttrsForDirectiveMatching` in /// `packages/compiler/src/render3/view/util.ts`): its static attributes with diff --git a/crates/oxc_angular_compiler/src/component/definition.rs b/crates/oxc_angular_compiler/src/component/definition.rs index ea4f317a5..ff0c56ea4 100644 --- a/crates/oxc_angular_compiler/src/component/definition.rs +++ b/crates/oxc_angular_compiler/src/component/definition.rs @@ -25,13 +25,14 @@ use super::metadata::{ use super::namespace_registry::NamespaceRegistry; use super::transform::TransformOptions; use crate::directive::{ - create_host_directive_mappings_array, create_inputs_literal, create_outputs_literal, + R3HostDirectiveMetadata, create_host_directives_feature_arg, create_inputs_literal, + create_outputs_literal, }; use crate::factory::create_invalid_factory_call; use crate::output::ast::{ - FnParam, FunctionExpr, InstantiateExpr, InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, - LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, - ReadVarExpr, ReturnStatement, + ArrowFunctionBody, ArrowFunctionExpr, FnParam, FunctionExpr, InstantiateExpr, + InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, + LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, ReturnStatement, }; use crate::pipeline::compilation::{ComponentCompilationJob, ConstValue}; use crate::pipeline::emit::HostBindingCompilationResult; @@ -322,7 +323,7 @@ fn generate_cmp_definition<'a>( false, )); - // 18. consts: [...] or consts: function() { ...initializers...; return [...]; } + // 18. consts: [...] or consts: () => { ...initializers...; return [...]; } // Per Angular compiler.ts lines 260-268: // - If there are const initializers (e.g., for i18n dual-mode), wrap in arrow function // - Otherwise, emit as plain literal array @@ -334,7 +335,7 @@ fn generate_cmp_definition<'a>( let consts_value = if !job.consts_initializers.is_empty() { // Wrap consts in an arrow function that runs initializers first - // function() { ...initializers...; return [...consts...]; } + // () => { ...initializers...; return [...consts...]; } let mut fn_stmts: OxcVec<'a, OutputStatement<'a>> = OxcVec::with_capacity_in(job.consts_initializers.len() + 1, &allocator); @@ -355,11 +356,10 @@ fn generate_cmp_definition<'a>( &allocator, ))); - OutputExpression::Function(Box::new_in( - FunctionExpr { - name: None, + OutputExpression::ArrowFunction(Box::new_in( + ArrowFunctionExpr { params: OxcVec::new_in(&allocator), - statements: fn_stmts, + body: ArrowFunctionBody::Statements(fn_stmts), source_span: None, }, &allocator, @@ -415,6 +415,7 @@ fn generate_cmp_definition<'a>( content_attr, host_attr, options.minify_component_styles, + options.angular_version, ); if style.trim().is_empty() { continue; @@ -1169,7 +1170,8 @@ fn create_directive_reference<'a>( /// Create the host directives argument array. /// -/// Format depends on whether directives have input/output mappings: +/// Builds each directive reference (namespaced for imports) and hands them to +/// the `createHostDirectivesFeatureArg` port the directive compiler uses: /// - Simple: [Directive1, Directive2] or [i1.ExternalDir] for imports /// - With mappings: [{ directive: Directive1, inputs: ['prop', 'alias'] }] /// - Forward ref: function() { return [Directive1] } @@ -1180,75 +1182,17 @@ fn create_host_directives_arg<'a>( host_directives: &[HostDirectiveMetadata<'a>], namespace_registry: &mut NamespaceRegistry<'a>, ) -> OutputExpression<'a> { - let mut expressions: OxcVec<'a, OutputExpression<'a>> = - OxcVec::with_capacity_in(host_directives.len(), &allocator); - let mut has_forward_ref = false; - - for directive in host_directives { - if directive.is_forward_reference { - has_forward_ref = true; - } - - // Create the directive reference (potentially namespaced) - let directive_ref = create_directive_reference(allocator, directive, namespace_registry); - - if !directive.has_mappings() { - // Simple case: just the directive type - expressions.push(directive_ref); - } else { - // Complex case: object with directive, inputs, outputs - let mut entries: OxcVec<'a, LiteralMapEntry<'a>> = OxcVec::new_in(&allocator); - - // directive: DirectiveClass (or i1.DirectiveClass for imports) - entries.push(LiteralMapEntry::new(Ident::from("directive"), directive_ref, false)); - - // inputs: ['internalName', 'publicName', ...] - if !directive.inputs.is_empty() { - let inputs_array = - create_host_directive_mappings_array(allocator, &directive.inputs); - entries.push(LiteralMapEntry::new(Ident::from("inputs"), inputs_array, false)); - } - - // outputs: ['internalName', 'publicName', ...] - if !directive.outputs.is_empty() { - let outputs_array = - create_host_directive_mappings_array(allocator, &directive.outputs); - entries.push(LiteralMapEntry::new(Ident::from("outputs"), outputs_array, false)); - } - - expressions.push(OutputExpression::LiteralMap(Box::new_in( - LiteralMapExpr { entries, source_span: None }, - &allocator, - ))); - } - } - - let array_expr = OutputExpression::LiteralArray(Box::new_in( - LiteralArrayExpr { entries: expressions, source_span: None }, - &allocator, - )); - - // If there's a forward reference, wrap in: function() { return [directives] } - if has_forward_ref { - let mut statements: OxcVec<'a, OutputStatement<'a>> = - OxcVec::with_capacity_in(1, &allocator); - statements.push(OutputStatement::Return(Box::new_in( - ReturnStatement { value: array_expr, source_span: None }, - &allocator, - ))); - - OutputExpression::Function(Box::new_in( - FunctionExpr { - name: None, - params: OxcVec::new_in(&allocator), - statements, - source_span: None, - }, - &allocator, - )) - } else { - array_expr - } + let entries: std::vec::Vec<_> = host_directives + .iter() + .map(|directive| R3HostDirectiveMetadata { + directive: create_directive_reference(allocator, directive, namespace_registry), + is_forward_reference: directive.is_forward_reference, + has_mapping_keys: directive.has_mapping_keys, + inputs: OxcVec::from_iter_in(directive.inputs.iter().copied(), &allocator), + outputs: OxcVec::from_iter_in(directive.outputs.iter().copied(), &allocator), + }) + .collect(); + create_host_directives_feature_arg(allocator, &entries) } /// Generate `ɵɵExternalStylesFeature(['style.css', ...])` expression. diff --git a/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs new file mode 100644 index 000000000..c7e212506 --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/hmr_fingerprint.rs @@ -0,0 +1,190 @@ +//! A fingerprint of a component file that ignores its inline template and +//! styles, for the HMR versus full-reload decision. +//! +//! A build tool keeps the fingerprint of each component file it compiled. +//! When the file changes, it takes the fingerprint again: if the two are +//! equal, the edit stayed inside the `template:` / `styles:` values of its +//! components, so an HMR update is enough. Anything else (the class body, an +//! import, another decorator field) changes the fingerprint, and the page +//! has to reload. +//! +//! The fingerprint covers the whole source text with the inside of those +//! values left out. Only the components the compiler compiled are looked at +//! (the caller names them), and only through Angular's own `@Component`, found +//! by its `@angular/core` import like the transform finds it. A value counts +//! only when it is one literal, so `'

' + SUFFIX` or `STYLES` stay in the +//! fingerprint and an edit to them reloads. + +use std::hash::{DefaultHasher, Hasher}; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Argument, Class, Declaration, ExportDefaultDeclarationKind, Expression, ObjectPropertyKind, + Program, PropertyKey, PropertyKind, Statement, +}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType, Span}; + +use super::decorator::find_component_decorator; +use crate::directive::{StringConsts, collect_string_consts}; + +/// The HMR fingerprint of a component file. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ComponentHmrFingerprint { + /// Hash of the source with every excluded range left out. Two sources + /// that differ only inside excluded ranges have the same fingerprint. + pub fingerprint: String, + /// The class names whose `@Component` was read, in source order. + pub components: Vec, + /// The ranges left out of the fingerprint, in source order: the inside of + /// each `template` / `styles` value, its quotes or brackets excluded. + pub excluded: Vec, +} + +/// Parse `source` and take its HMR fingerprint for the components named in +/// `class_names` (the ones the transform compiled). +/// +/// A source with syntax errors excludes nothing: what the parser recovered +/// is not something to trust with an HMR decision, so any edit that leaves +/// it broken reloads the page. +pub fn component_hmr_fingerprint( + allocator: &Allocator, + path: &str, + source: &str, + class_names: &[&str], +) -> ComponentHmrFingerprint { + if class_names.is_empty() { + return fingerprint_with_exclusions(source, Vec::new(), Vec::new()); + } + let source_type = SourceType::from_path(path).unwrap_or_default(); + let parser_ret = Parser::new(allocator, source, source_type).parse(); + if !parser_ret.diagnostics.is_empty() { + return fingerprint_with_exclusions(source, Vec::new(), Vec::new()); + } + let consts = collect_string_consts(allocator, &parser_ret.program); + hmr_fingerprint_of_program(&parser_ret.program, source, &consts, |name| { + class_names.contains(&name) + }) +} + +/// [`component_hmr_fingerprint`] on an already parsed program. `consts` must +/// be collected from the same program; `is_compiled` says which class names +/// to read. +pub(super) fn hmr_fingerprint_of_program( + program: &Program<'_>, + source: &str, + consts: &StringConsts<'_>, + is_compiled: impl Fn(&str) -> bool, +) -> ComponentHmrFingerprint { + let mut components = Vec::new(); + let mut excluded = Vec::new(); + for stmt in &program.body { + let Some(class) = top_level_class(stmt) else { continue }; + let Some(id) = &class.id else { continue }; + if !is_compiled(id.name.as_str()) { + continue; + } + if collect_excluded_ranges(class, consts, &mut excluded) { + components.push(id.name.to_string()); + } + } + excluded.sort_unstable_by_key(|span| span.start); + fingerprint_with_exclusions(source, components, excluded) +} + +/// A class declared at the top level, the places the transform compiles. +fn top_level_class<'a>(stmt: &'a Statement<'a>) -> Option<&'a Class<'a>> { + match stmt { + Statement::ClassDeclaration(class) => Some(class), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class), + _ => None, + }, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => Some(class), + _ => None, + }, + _ => None, + } +} + +/// Push the inside of each literal `template` / `styles` value in `class`'s +/// `@Component({...})`. Returns whether the class has that decorator with an +/// object literal argument. +fn collect_excluded_ranges( + class: &Class<'_>, + consts: &StringConsts<'_>, + excluded: &mut Vec, +) -> bool { + let Some(decorator) = find_component_decorator(&class.decorators, consts) else { + return false; + }; + let Expression::CallExpression(call) = &decorator.expression else { return false }; + let Some(Argument::ObjectExpression(config)) = call.arguments.first() else { + return false; + }; + for property in &config.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { continue }; + // `get template() {}`, `styles() {}` and `{ styles }` name the field + // but hold no literal; a computed key's name is not read. + if property.kind != PropertyKind::Init + || property.method + || property.shorthand + || property.computed + { + continue; + } + let name = match &property.key { + PropertyKey::StaticIdentifier(id) => Some(id.name.as_str()), + PropertyKey::StringLiteral(lit) => lit.value.as_str(), + _ => None, + }; + // `template` is a string; `styles` is a string or an array of them. + let literal = match (name, &property.value) { + ( + Some("template" | "styles"), + Expression::StringLiteral(_) | Expression::TemplateLiteral(_), + ) + | (Some("styles"), Expression::ArrayExpression(_)) => property.value.span(), + _ => continue, + }; + // Keep the quotes or brackets: `styles: []` and `styles: ''` stay + // apart, and the value's shape is part of the fingerprint. + if literal.end > literal.start + 2 { + excluded.push(Span::new(literal.start + 1, literal.end - 1)); + } + } + true +} + +/// Hash `source` without `excluded` (sorted, non-overlapping). +fn fingerprint_with_exclusions( + source: &str, + components: Vec, + excluded: Vec, +) -> ComponentHmrFingerprint { + // Two independently seeded 64-bit SipHashes plus the kept length. Both + // fingerprints of a comparison come from the same build, so the std + // hasher's fixed keys are enough; it is not meant to resist an attacker. + let mut first = DefaultHasher::new(); + let mut second = DefaultHasher::new(); + second.write_u64(0x9e37_79b9_7f4a_7c15); + let bytes = source.as_bytes(); + let mut kept = 0usize; + let mut cursor = 0usize; + let mut write = |segment: &[u8]| { + first.write(segment); + second.write(segment); + kept += segment.len(); + }; + for span in &excluded { + write(&bytes[cursor..span.start as usize]); + cursor = span.end as usize; + } + write(&bytes[cursor..]); + ComponentHmrFingerprint { + fingerprint: format!("{:016x}{:016x}-{kept:x}", first.finish(), second.finish()), + components, + excluded, + } +} diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index b46175196..7553487a1 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -90,8 +90,16 @@ impl<'a> ImportElisionAnalyzer<'a> { /// decorators are stripped outright and the imports can be dropped. pub fn analyze(program: &'a Program<'a>, emit_metadata: bool) -> Self { let semantic_ret = SemanticBuilder::new().build(program); - let semantic = &semantic_ret.semantic; + Self::analyze_with_semantic(program, &semantic_ret.semantic, emit_metadata) + } + /// [`Self::analyze`] with a semantic model the caller already built for + /// `program`, so one build can serve several passes. + pub fn analyze_with_semantic( + program: &'a Program<'a>, + semantic: &Semantic<'a>, + emit_metadata: bool, + ) -> Self { let mut type_only_specifiers = FxHashSet::default(); let mut value_reference_counts = FxHashMap::default(); let mut declaration_specifiers = Vec::new(); diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 318e55935..b97bc38d8 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -219,6 +219,39 @@ impl AngularVersion { self.is_at_least(22, 0, 0) } + /// Check if this version compiles component templates in DOM-only mode + /// (v20.1.0-next.3+). + /// + /// From that release a full build compiles the template of a standalone + /// component without directive dependencies with `ɵɵdomElementStart`, + /// `ɵɵdomListener`, `ɵɵdomProperty` and the other DOM-only instructions + /// (`TemplateCompilationMode.DomOnly`). `20.1.0-next.2` and earlier always + /// use the full instructions. + pub fn supports_dom_only_templates(&self) -> bool { + if self.major == 20 && self.minor == 1 && self.patch == 0 { + return match self.prerelease { + None => true, + Some(prerelease) => prerelease >= Prerelease::Next(3), + }; + } + self.is_at_least(20, 1, 0) + } + + /// Check if this version's `ShadowCss` still runs the legacy Shadow DOM + /// polyfill passes (before v22.0.0-rc.0). + /// + /// Until then `shimCssText` rewrote `polyfill-next-selector`, + /// `polyfill-rule` and `polyfill-unscoped-rule` blocks and replaced the + /// `::shadow` / `::content` combinators with a space. v22.0.0-rc.0 dropped + /// all four passes (`22.0.0-next.12` still has them), so a v22 stylesheet + /// scopes those tokens like any other selector text. + pub fn uses_shadow_css_polyfills(&self) -> bool { + if self.major == 22 && self.minor == 0 && self.patch == 0 { + return matches!(self.prerelease, Some(prerelease) if prerelease < Prerelease::Rc(0)); + } + self.major < 22 + } + /// Check if this version is `major.minor.patch` or newer. /// /// Compares the release triple only: a prerelease of `22.3.0` counts as @@ -514,6 +547,17 @@ pub struct ComponentMetadata<'a> { /// requires cross-file selector info OXC doesn't have. pub deferred_imports: Vec<'a, Ident<'a>>, + /// `true` when the decorator provably brings nothing into the template's + /// scope: `imports` and `deferredImports` are each absent or an empty + /// array literal, and the config object has no spread that could add + /// them. `false` when unknown. + /// + /// A standalone component whose scope is provably empty can only match + /// itself in its template, which is what a full ngtsc build checks before + /// compiling the template in DOM-only mode (`hasDirectiveDependencies` in + /// `packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts`). + pub imports_provably_empty: bool, + /// Animation triggers for the component. /// /// Corresponds to the `animations` property in `@Component`. @@ -563,6 +607,11 @@ pub struct HostDirectiveMetadata<'a> { /// Whether this is a forward reference (requires wrapping in a function). pub is_forward_reference: bool, + + /// Whether `inputs` or `outputs` was written, even as an empty array. + /// ngtsc keeps those as non-null mappings, so the entry is passed to + /// `ɵɵHostDirectivesFeature` as `{ directive }` instead of the bare class. + pub has_mapping_keys: bool, } impl<'a> HostDirectiveMetadata<'a> { @@ -574,6 +623,7 @@ impl<'a> HostDirectiveMetadata<'a> { inputs: Vec::new_in(&allocator), outputs: Vec::new_in(&allocator), is_forward_reference: false, + has_mapping_keys: false, } } @@ -819,6 +869,7 @@ impl<'a> ComponentMetadata<'a> { declaration_list_emit_mode: DeclarationListEmitMode::default(), raw_imports: None, deferred_imports: Vec::new_in(&allocator), + imports_provably_empty: false, animations: None, schemas: Vec::new_in(&allocator), is_signal: false, @@ -936,4 +987,56 @@ mod tests { assert!(!version.supports_incremental_hydration_runtime(), "{version:?}"); } } + + #[test] + fn dom_only_templates_start_at_20_1_0_next_3() { + let parse = |s| AngularVersion::parse(s).unwrap(); + + for version in [ + parse("20.1.0-next.3"), + parse("20.1.0-rc.0"), + parse("20.1.0"), + parse("20.3.4"), + parse("21.0.0-next.0"), + parse("22.2.2"), + ] { + assert!(version.supports_dom_only_templates(), "{version:?}"); + } + + for version in [ + parse("19.2.0"), + parse("20.0.7"), + parse("20.1.0-next.2"), + // Unknown tags parse to `Other`, below `next`. + parse("20.1.0-beta.1"), + ] { + assert!(!version.supports_dom_only_templates(), "{version:?}"); + } + } + + #[test] + fn shadow_css_polyfills_end_at_22_0_0_rc_0() { + let parse = |s| AngularVersion::parse(s).unwrap(); + + for version in [ + parse("19.2.25"), + parse("20.3.18"), + parse("21.2.25"), + parse("22.0.0-next.12"), + // Unknown tags parse to `Other`, below `next`. + parse("22.0.0-beta.1"), + ] { + assert!(version.uses_shadow_css_polyfills(), "{version:?}"); + } + + for version in [ + parse("22.0.0-rc.0"), + parse("22.0.0"), + parse("22.2.2"), + parse("23.0.0"), + AngularVersion::new(u32::MAX, 0, 0), + ] { + assert!(!version.uses_shadow_css_polyfills(), "{version:?}"); + } + } } diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 4736641f7..2ca029c04 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -12,6 +12,7 @@ mod decorator; mod defer_resolver; mod definition; mod dependency; +mod hmr_fingerprint; mod hoist; mod import_elision; mod metadata; @@ -29,6 +30,7 @@ pub use dependency::{ FactoryTarget, InjectFlags, R3DependencyMetadata, compile_inject_dependencies, generate_factory_di_args, get_inject_fn_name, }; +pub use hmr_fingerprint::{ComponentHmrFingerprint, component_hmr_fingerprint}; pub use import_elision::ImportElisionAnalyzer; pub use metadata::{ AngularVersion, ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, @@ -37,10 +39,11 @@ pub use metadata::{ }; pub use namespace_registry::NamespaceRegistry; pub use transform::{ - CompiledComponent, HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap, - LinkerHostBindingOutput, LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput, - TransformOptions, TransformResult, build_import_map, compile_component_template, + CompiledComponent, ComponentHmrUpdate, ComponentHmrUpdateInput, ComponentResourceUrls, + HmrTemplateCompileOutput, HostMetadataInput, ImportInfo, ImportMap, LinkerHostBindingOutput, + LinkerTemplateOutput, ResolvedResources, TemplateCompileOutput, TransformOptions, + TransformResult, build_import_map, compile_component_hmr_update, compile_component_template, compile_for_hmr, compile_host_bindings_for_linker, compile_template_for_hmr, compile_template_for_linker, compile_template_to_js, compile_template_to_js_with_options, - transform_angular_file, + extract_component_resource_urls, transform_angular_file, }; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 54d1ff031..bfd019801 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -56,8 +56,8 @@ use crate::ng_module::{ extract_ng_module_metadata_in, find_ng_module_decorator, generate_full_ng_module_definition, }; use crate::output::ast::{ - DeclareFunctionStmt, FunctionExpr, OutputExpression, OutputStatement, ReadPropExpr, - ReadVarExpr, StmtModifier, + ArrowFunctionBody, ArrowFunctionExpr, DeclareFunctionStmt, FunctionExpr, OutputExpression, + OutputStatement, ReadPropExpr, ReadVarExpr, StmtModifier, }; use crate::output::emitter::JsEmitter; use crate::parser::ParseTemplateOptions; @@ -333,13 +333,35 @@ pub struct TransformResult { /// For HMR: maps the ID of each compiled component (path@ClassName) to its /// `@Component` decorator's callee as written in the source (`Component`, /// `Cmp` for `import { Component as Cmp }`, `ng.Component` for a namespace - /// import). It lets a build tool find the decorator the compiler took - /// without deciding again which decorators are Angular's. + /// import). + /// + /// Deprecated: kept for existing callers; the bundled Vite plugin no + /// longer reads it and uses [`Self::hmr_fingerprint`] instead. pub component_decorators: HashMap, + /// For HMR: the fingerprint of the source with the inline `template` / + /// `styles` values of the compiled components left out (see + /// [`super::component_hmr_fingerprint`]). A build tool compares it with + /// the fingerprint of the edited file, taken for the same class names: + /// when they are equal, only those values changed and an HMR update is + /// enough. `None` unless `hmr` is on. + pub hmr_fingerprint: Option, + /// Compilation diagnostics (errors and warnings). pub diagnostics: Vec, + /// Stylesheet ``s in compiled templates (``) whose `href` was not in the resolved styles, as written. + /// + /// ngtsc loads these like `styleUrls`, relative to the component file. + /// They are only known once the template is parsed, so a build tool + /// learns them here: it can read them, add them to + /// [`ResolvedResources::styles`] and transform again. Each entry also has + /// a COMPONENT_RESOURCE_NOT_FOUND error in `diagnostics` (one per + /// component that links it), and only resources that were passed are + /// checked. + pub missing_link_urls: Vec, + /// Number of components found in the file. pub component_count: usize, @@ -414,6 +436,12 @@ pub struct HmrTemplateCompileOutput { /// These must be included in the HMR update module to avoid constant pool mismatches. pub styles: std::vec::Vec, + /// The `href`s of the template's stylesheet ``s, as written. The + /// full transform loads them like `styleUrls` (see + /// [`TransformResult::missing_link_urls`]); an HMR caller reads them the + /// same way and passes their contents with the component's other styles. + pub style_urls: std::vec::Vec, + /// The consts array as JavaScript code. /// This must be included in the HMR update module to match the template function's /// constant references. Without this, the template may reference indices that don't @@ -3029,13 +3057,33 @@ pub fn transform_angular_file( let parser_ret = Parser::new(allocator, source, source_type).parse(); // Collect parse errors - if !parser_ret.diagnostics.is_empty() { + let parsed_cleanly = parser_ret.diagnostics.is_empty(); + if !parsed_cleanly { for error in parser_ret.diagnostics { result.diagnostics.push(OxcDiagnostic::error(error.to_string())); } // Still continue to try to generate output for partial results } + // One semantic model of the source program, shared by import elision, + // the initializer-API validation and the TDZ hoist below. The program is + // never mutated after parsing (edits are applied to the source text), so + // it stays valid for all three. Only the validation reads the node store, + // so build it only when that rule will run. + // + // Semantic builder errors (redeclarations, etc.) are intentionally + // dropped: the parser already captured syntax errors into + // `result.diagnostics` above, and Semantic-level diagnostics aren't + // actionable for these passes — we treat the input as best-effort and + // rely on the host build to surface genuine errors. The JIT path (see + // `transform_angular_file_jit`) follows the same convention. + let semantic = oxc_semantic::SemanticBuilder::new() + .with_build_nodes(crate::validation::initializer_api_usage_needs_semantic( + &parser_ret.program, + )) + .build(&parser_ret.program) + .semantic; + // Run import elision analysis on the original program. // This identifies type-only imports that can be removed. // Must run BEFORE transformation to capture correct type vs value references. @@ -3043,8 +3091,9 @@ pub fn transform_angular_file( // `@Inject` tokens (`args: [TOKEN]`) and member decorators (`{type: Input}`) // by their imported names, so those imports must stay whenever metadata // will be emitted (ngtsc keeps them too). - let import_elision = ImportElisionAnalyzer::analyze( + let import_elision = ImportElisionAnalyzer::analyze_with_semantic( &parser_ret.program, + &semantic, options.emit_class_metadata && !options.advanced_optimizations, ); @@ -3112,6 +3161,10 @@ pub fn transform_angular_file( // by tracking the next index and passing it to each component. let mut shared_pool_index: u32 = 0; + // The components compiled for HMR, whose inline template and styles the + // HMR fingerprint leaves out. + let mut hmr_compiled_classes: Vec = Vec::new(); + // One analyzer serves both cross-file features: the type-only scan for // import elision and, when resolve_imported_values is on, the resolver the // decorator evaluator reads imported bindings through. It's declared @@ -3119,15 +3172,7 @@ pub fn transform_angular_file( #[cfg(feature = "cross_file_elision")] let cross_file_analyzer: Option = if options.cross_file_elision || options.resolve_imported_values { - let file_path = std::path::Path::new(path); - let base_dir = options.base_dir.as_deref().or_else(|| file_path.parent()); - base_dir.map(|base| { - CrossFileAnalyzer::new( - base, - options.tsconfig_path.as_deref(), - options.resolve_imported_values, - ) - }) + new_cross_file_analyzer(path, options) } else { None }; @@ -3187,22 +3232,17 @@ pub fn transform_angular_file( // references (e.g. `host: { [ATTR_NAME]: '' }`) the same way the official // Angular compiler does. `resolve_imported_values` teaches the evaluator // to read imported bindings the way ngtsc's checker does. - #[allow(unused_mut)] - let mut string_consts = collect_string_consts(allocator, &parser_ret.program); + let string_consts = collect_string_consts(allocator, &parser_ret.program); #[cfg(feature = "cross_file_elision")] - if options.resolve_imported_values - && let Some(analyzer) = &cross_file_analyzer - { - string_consts = string_consts.with_resolver(std::rc::Rc::new( - analyzer.value_resolver(std::path::PathBuf::from(path)), - )); - } + let string_consts = + with_imported_values(string_consts, cross_file_analyzer.as_ref(), path, options); // ngtsc's source-file rules (validation/): initializer APIs used outside a // class member initializer (UNSUPPORTED_INITIALIZER_API_USAGE). - result.diagnostics.extend(crate::validation::initializer_api_usage_errors( + result.diagnostics.extend(crate::validation::initializer_api_usage_errors_with_semantic( &parser_ret.program, &string_consts, + &semantic, )); #[cfg(feature = "cross_file_elision")] @@ -3571,7 +3611,8 @@ pub fn transform_angular_file( &import_map, resolved_resources, deferrable_selector, - &import_elision_mut, + Some(&import_elision_mut), + false, ) { Ok(compilation_result) => { // Update the shared pool index for the next component @@ -3595,6 +3636,9 @@ pub fn transform_angular_file( class_name, style_url ))); } + result + .missing_link_urls + .extend(compilation_result.missing_link_urls.iter().cloned()); let component_id = format!("{}@{}", path, class_name); @@ -3605,7 +3649,7 @@ pub fn transform_angular_file( if options.hmr { result.template_updates.insert( component_id.clone(), - compilation_result.template_js.clone(), + compilation_result.template_js.clone().unwrap_or_default(), ); if let Some(decorator) = component_decorator { let callee = match &decorator.expression { @@ -3618,6 +3662,7 @@ pub fn transform_angular_file( .to_string(), ); } + hmr_compiled_classes.push(class_name.clone()); } // Track the decorator span to remove @@ -3681,20 +3726,15 @@ pub fn transform_angular_file( // Split declarations into two groups: // 1. decls_before_class: child view functions, constants (needed BEFORE class) - // 2. decls_after_class: debug info, metadata, HMR code (references class, needs AFTER) + // 2. decls_after_class: metadata, debug info, HMR code (references class, needs AFTER) + // + // The order follows ngtsc's `compileResults` + // (annotations/common/src/util.ts): class metadata, then + // class debug info, then the HMR initializer. let decls_before_class = compilation_result.declarations_js.clone(); let mut decls_after_class = String::new(); - // Add class debug info (before class metadata, following Angular's order) - if let Some(debug_info_js) = &compilation_result.class_debug_info_js { - if !decls_after_class.is_empty() { - decls_after_class.push('\n'); - } - decls_after_class.push_str(debug_info_js); - decls_after_class.push(';'); - } - - // Add class metadata for TestBed support (after debug info, before HMR) + // Add class metadata for TestBed support (before debug info and HMR) // Only emit when enabled and not in advanced optimizations mode if options.emit_class_metadata && !options.advanced_optimizations { // List ALL of the class's `@angular/core` decorators, in @@ -3837,6 +3877,15 @@ pub fn transform_angular_file( } } + // Add class debug info (after class metadata, before HMR) + if let Some(debug_info_js) = &compilation_result.class_debug_info_js { + if !decls_after_class.is_empty() { + decls_after_class.push('\n'); + } + decls_after_class.push_str(debug_info_js); + decls_after_class.push(';'); + } + // Add HMR initializer last if let Some(hmr_js) = &compilation_result.hmr_initializer_js { if !decls_after_class.is_empty() { @@ -4285,8 +4334,10 @@ pub fn transform_angular_file( has_injectable, )); - // Emit setClassMetadata for TestBed support (overrideModule), - // appended after the NgModule's external declarations. + // Emit setClassMetadata for TestBed support (overrideModule). + // ngtsc's `insertMetadataStatement` (ng_module/src/handler.ts) + // unshifts it in front of the ɵmod statements, so it comes + // before the `ɵɵsetNgModuleScope` IIFE. let metadata = build_set_class_metadata_decls( &allocator, class, @@ -4299,9 +4350,9 @@ pub fn transform_angular_file( ); if !metadata.is_empty() { if !external_decls.is_empty() { - external_decls.push('\n'); + external_decls.insert(0, '\n'); } - external_decls.push_str(&metadata); + external_decls.insert_str(0, &metadata); } // NgModule: external_decls go AFTER the class (they reference the class name) @@ -4643,28 +4694,19 @@ pub fn transform_angular_file( // reference at class-definition time and throw `ReferenceError`. See // issue #287. // - // The hoister resolves identifier references through `oxc_semantic` so - // a nested-scope shadow of a top-level name can't be mistaken for the - // top-level binding itself. + // The hoister resolves identifier references through `oxc_semantic` (the + // file-level model built after parsing) so a nested-scope shadow of a + // top-level name can't be mistaken for the top-level binding itself. // - // Gate the Semantic build behind a cheap top-level scan: a real Angular - // codebase contains plenty of plain `.ts` helpers, type-only modules, and - // services without `@Injectable` that we route through this function. For - // those, building a full symbol table just to discover there's nothing to - // hoist is pure overhead. + // Gate the hoist behind a cheap top-level scan: a real Angular codebase + // contains plenty of plain `.ts` helpers, type-only modules, and services + // without `@Injectable` that we route through this function, and for + // those there is nothing to hoist. if program_has_angular_decorated_class(&parser_ret.program, &import_map, &string_consts) { - // Semantic builder errors (redeclarations, etc.) are intentionally - // dropped: the parser already captured syntax errors into - // `result.diagnostics` upstream, and Semantic-level diagnostics here - // aren't actionable for the hoist pass — we treat the input as - // best-effort and rely on the host build to surface genuine errors. - // The JIT path (see ~line 1380) follows the same convention. - let hoist_semantic = - oxc_semantic::SemanticBuilder::new().build(&parser_ret.program).semantic; edits.extend(collect_hoist_edits( &parser_ret.program, source, - &hoist_semantic, + &semantic, &import_map, &string_consts, )); @@ -4710,13 +4752,32 @@ pub fn transform_angular_file( result.dependencies.extend(analyzer.take_value_dependencies()); } + // Taken from this parse, for exactly the classes compiled above, so it + // equals what `component_hmr_fingerprint` gives for this source and + // these class names. + if options.hmr { + let fingerprint = if parsed_cleanly { + super::hmr_fingerprint::hmr_fingerprint_of_program( + &parser_ret.program, + source, + &string_consts, + |name| hmr_compiled_classes.iter().any(|compiled| compiled == name), + ) + } else { + super::component_hmr_fingerprint(allocator, path, source, &[]) + }; + result.hmr_fingerprint = Some(fingerprint.fingerprint); + } + result } /// Result of full component compilation including ɵcmp/ɵfac. struct FullCompilationResult { - /// Compiled template function as JavaScript. - template_js: String, + /// Compiled template function as JavaScript, for HMR template updates. + /// `None` when HMR is off (nothing reads it then), for an HMR update + /// compile (see `hmr_update`) and in partial mode. + template_js: Option, /// ɵcmp definition as JavaScript. cmp_js: String, @@ -4765,6 +4826,394 @@ struct FullCompilationResult { /// a missing decorator `styleUrl`: the caller surfaces /// COMPONENT_RESOURCE_NOT_FOUND in the result diagnostics. missing_link_urls: Vec, + + /// The template fields of the ɵcmp, for an HMR update compile only. + hmr_update: Option, +} + +/// What an HMR update module needs from a component's full compile: the +/// definition fields its template decides, as emitted in the ɵcmp. +struct HmrUpdateParts { + template_js: String, + /// The template's constant declarations (child view functions, pooled + /// constants) the fields refer to. + declarations_js: String, + consts_js: Option, + decls: u32, + vars: u32, + ng_content_selectors_js: Option, + /// The ɵcmp's `styles`. + styles: Vec, +} + +/// The analyzer behind both cross-file features (`cross_file_elision`'s +/// type-only scan and `resolve_imported_values`), resolving modules from +/// `path`'s directory (or `base_dir`) with `tsconfig_path`'s aliases. +#[cfg(feature = "cross_file_elision")] +fn new_cross_file_analyzer(path: &str, options: &TransformOptions) -> Option { + let file_path = std::path::Path::new(path); + let base_dir = options.base_dir.as_deref().or_else(|| file_path.parent()); + base_dir.map(|base| { + CrossFileAnalyzer::new( + base, + options.tsconfig_path.as_deref(), + options.resolve_imported_values, + ) + }) +} + +/// `string_consts` with `analyzer`'s import value resolver attached when +/// `resolve_imported_values` is on, so decorator metadata reads a binding +/// imported from another file the way ngtsc's checker does. +#[cfg(feature = "cross_file_elision")] +fn with_imported_values<'a>( + string_consts: crate::directive::StringConsts<'a>, + analyzer: Option<&CrossFileAnalyzer>, + path: &str, + options: &TransformOptions, +) -> crate::directive::StringConsts<'a> { + match analyzer { + Some(analyzer) if options.resolve_imported_values => string_consts.with_resolver( + std::rc::Rc::new(analyzer.value_resolver(std::path::PathBuf::from(path))), + ), + _ => string_consts, + } +} + +/// The external resources the `@Component` classes of a file name. +#[derive(Debug, Default)] +pub struct ComponentResourceUrls { + /// Each component's `templateUrl`, as written. + pub template_urls: Vec, + /// Each component's `styleUrl` / `styleUrls` entries, as written. + pub style_urls: Vec, + /// The files read to resolve an imported URL binding (only with + /// `resolve_imported_values`), like [`TransformResult::dependencies`]. + pub value_dependencies: Vec, +} + +/// The `templateUrl` and `styleUrl(s)` of every `@Component` class in a file: +/// the files a build tool reads and passes to [`transform_angular_file`] as +/// [`ResolvedResources`], keyed by these same strings. +/// +/// The URLs are read exactly as the transform reads them — the same +/// evaluation as [`extract_component_metadata`] over the same consts, with the +/// cross-file resolver attached under the same `options` +/// (`resolve_imported_values`, `base_dir`, `tsconfig_path`) — so a URL the +/// transform will look up is never one this skipped (an imported +/// `const TEMPLATE_URL`, say). Only the URL properties are evaluated, and the +/// resolver reads a file only when one of them names an imported binding, so +/// literal URLs cost no cross-file read even when `host` or `styles` are +/// imported. Stylesheet +/// ``s inside a template are only known once the template is parsed; +/// the transform reports the ones it was not given in +/// [`TransformResult::missing_link_urls`]. +pub fn extract_component_resource_urls( + allocator: &Allocator, + path: &str, + source: &str, + options: Option<&TransformOptions>, +) -> ComponentResourceUrls { + let default_options = TransformOptions::default(); + let options = options.unwrap_or(&default_options); + + let source_type = SourceType::from_path(path).unwrap_or_default(); + let parser_ret = Parser::new(allocator, source, source_type).parse(); + let program = &parser_ret.program; + + #[cfg(feature = "cross_file_elision")] + let analyzer = + if options.resolve_imported_values { new_cross_file_analyzer(path, options) } else { None }; + let string_consts = collect_string_consts(allocator, program); + #[cfg(feature = "cross_file_elision")] + let string_consts = with_imported_values(string_consts, analyzer.as_ref(), path, options); + + let mut urls = ComponentResourceUrls::default(); + for stmt in &program.body { + let class = match stmt { + Statement::ClassDeclaration(class) => class.as_ref(), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), + _ => continue, + }, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => class.as_ref(), + _ => continue, + }, + _ => continue, + }; + let Some((template_url, style_urls)) = + super::decorator::extract_component_resource_urls_of(allocator, class, &string_consts) + else { + continue; + }; + if let Some(template_url) = template_url { + urls.template_urls.push(template_url.to_string()); + } + urls.style_urls.extend(style_urls.iter().map(ToString::to_string)); + } + #[cfg(feature = "cross_file_elision")] + if let Some(analyzer) = &analyzer { + urls.value_dependencies.extend(analyzer.take_value_dependencies()); + urls.value_dependencies.sort_unstable(); + } + urls +} + +/// Inputs of [`compile_component_hmr_update`] besides the file and options. +#[derive(Debug, Default)] +pub struct ComponentHmrUpdateInput<'s> { + /// The class to compile the update for. + pub class_name: &'s str, + /// The `hmr_fingerprint` of the source the running component was compiled + /// from (see [`TransformResult::hmr_fingerprint`]). When given and this + /// source has another fingerprint, it differs outside the inline + /// `template` / `styles` values, so styles that come out empty are not + /// trusted to clear the live ones. + pub source_fingerprint: Option<&'s str>, + /// The class names that fingerprint was taken for (the file's compiled + /// components). Defaults to `class_name` alone. + pub compiled_class_names: Option<&'s [&'s str]>, +} + +/// An HMR update for one component, from [`compile_component_hmr_update`]. +#[derive(Debug, Default)] +pub struct ComponentHmrUpdate { + /// The update module (see [`crate::generate_hmr_update_module`]). `None` + /// when there is nothing to send: the class is not a compiled + /// `@Component`, its template is empty or missing from the resources, or + /// compiling it failed (see `diagnostics`). + pub module: Option, + + /// The component ID (`path@ClassName`). + pub component_id: String, + + /// The template function the module installs, as emitted in the ɵcmp. + pub template_js: String, + + /// The styles the module installs, as emitted in the ɵcmp. `None` when + /// they are not known (a stylesheet missing from the resources with + /// nothing else to send, `styles` the evaluator could not read, or a + /// source that differs from `source_fingerprint`); the module then keeps + /// the live ones. + pub styles: Option>, + + /// The class's `templateUrl`, as written. + pub template_url: Option, + + /// The class's `styleUrl` / `styleUrls` entries, as written. + pub style_urls: Vec, + + /// The `href` of each stylesheet `` in the template, as written. + pub template_style_urls: Vec, + + /// The `templateUrl` when it was not in the resources. Nothing is compiled + /// then; read it and call again. + pub missing_template_url: Option, + + /// `styleUrls` and template `` hrefs that were not in the resources. + /// Read the ones that can be read and call again. + pub missing_style_urls: Vec, + + /// The HMR fingerprint of `source` for `compiled_class_names`, as + /// [`super::component_hmr_fingerprint`] computes it. `None` when the + /// source did not parse. + pub fingerprint: Option, + + /// Errors (and other diagnostics) of the parse and the compile. + pub diagnostics: Vec, +} + +/// Compile the HMR update module of one component with the options of the +/// full transform. +/// +/// Takes the same `options` and `resolved_resources` as +/// [`transform_angular_file`] and does what it does for the class, from one +/// parse of `source`: the same metadata extraction (same-file constants and, +/// with `resolve_imported_values`, imported ones), the same template and style +/// resolution, and the same template compile (`preserveWhitespaces`, i18n +/// message ids, Angular version, debug locations for `path`, template +/// stylesheet links). The update's `template`, `consts`, `decls`, `vars`, +/// `ngContentSelectors` and `styles` are those of the ɵcmp the transform +/// emits, with two exceptions: +/// +/// - constants are numbered from `_c0`, as for the first component of a file +/// (the update module declares its own); +/// - `@angular/core` is `i0` (the module binds it) and a `@defer` block whose +/// class lists `deferredImports` gets no dependency function, since the +/// dynamic imports would resolve against the module's own URL. +/// +/// HMR always compiles the full (AOT) output; `jit` and partial +/// `compilation_mode` are ignored. +pub fn compile_component_hmr_update( + allocator: &Allocator, + path: &str, + source: &str, + input: &ComponentHmrUpdateInput<'_>, + options: Option<&TransformOptions>, + resolved_resources: Option<&ResolvedResources>, +) -> ComponentHmrUpdate { + let default_options = TransformOptions::default(); + let options = options.unwrap_or(&default_options); + let options_full; + let options = if options.jit || options.compilation_mode != crate::CompilationMode::Full { + options_full = TransformOptions { + jit: false, + compilation_mode: crate::CompilationMode::Full, + ..options.clone() + }; + &options_full + } else { + options + }; + let empty_resources = ResolvedResources::default(); + let resources = resolved_resources.unwrap_or(&empty_resources); + let class_name = input.class_name; + + let mut update = ComponentHmrUpdate { + component_id: format!("{path}@{class_name}"), + ..ComponentHmrUpdate::default() + }; + + let source_type = SourceType::from_path(path).unwrap_or_default(); + let parser_ret = Parser::new(allocator, source, source_type).parse(); + if !parser_ret.diagnostics.is_empty() { + update.diagnostics.extend( + parser_ret.diagnostics.into_iter().map(|e| OxcDiagnostic::error(e.to_string())), + ); + return update; + } + let program = &parser_ret.program; + + #[cfg(feature = "cross_file_elision")] + let cross_file_analyzer = + if options.resolve_imported_values { new_cross_file_analyzer(path, options) } else { None }; + let string_consts = collect_string_consts(allocator, program); + #[cfg(feature = "cross_file_elision")] + let string_consts = + with_imported_values(string_consts, cross_file_analyzer.as_ref(), path, options); + + let default_class_names = [class_name]; + let compiled_class_names = input.compiled_class_names.unwrap_or(&default_class_names); + let fingerprint = super::hmr_fingerprint::hmr_fingerprint_of_program( + program, + source, + &string_consts, + |name| compiled_class_names.contains(&name), + ) + .fingerprint; + let source_matches = input.source_fingerprint.is_none_or(|expected| expected == fingerprint); + update.fingerprint = Some(fingerprint); + + let Some(class) = program.body.iter().find_map(|stmt| { + let class = match stmt { + Statement::ClassDeclaration(class) => class.as_ref(), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => class.as_ref(), + _ => return None, + }, + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::ClassDeclaration(class) => class.as_ref(), + _ => return None, + }, + _ => return None, + }; + class.id.as_ref().is_some_and(|id| id.name == class_name).then_some(class) + }) else { + return update; + }; + // `jit: true` opts the class out of AOT; the transform compiles no ɵcmp. + if find_jit_forced_decorator(class, &string_consts).is_some() { + return update; + } + + let import_map = build_import_map(allocator, &program.body, options.resolved_imports.as_ref()); + let Some(mut metadata) = extract_component_metadata( + allocator, + class, + options.implicit_standalone(), + &import_map, + Some(source), + &string_consts, + ) else { + return update; + }; + + update.template_url = metadata.template_url.as_ref().map(ToString::to_string); + update.style_urls = metadata.style_urls.iter().map(ToString::to_string).collect(); + let styles_known = metadata.styles_resolved; + update.missing_style_urls = resolve_styles(allocator, &mut metadata, Some(resources)); + let (template, missing_template_url) = resolve_template(&metadata, Some(resources)); + if missing_template_url.is_some() { + update.missing_template_url = missing_template_url; + return update; + } + // An empty template is what an editor's truncate-then-write leaves for a + // moment; there is nothing to send until the next save. + let Some(template) = template.filter(|template| !template.is_empty()) else { + return update; + }; + let template = allocator.alloc_str(&template); + + let (view_queries, content_queries) = + extract_class_queries(allocator, class, Some(source), &string_consts); + // `i0` like the update module binds it, whatever alias the file needs. + let mut namespace_registry = NamespaceRegistry::new(allocator); + let compiled = match compile_component_full( + allocator, + template, + &mut metadata, + path, + source, + options, + view_queries, + content_queries, + 0, + &mut namespace_registry, + &import_map, + Some(resources), + None, + None, + true, + ) { + Ok(compiled) => compiled, + Err(diagnostics) => { + update.diagnostics = diagnostics; + return update; + } + }; + update.template_style_urls = compiled.template_style_urls; + update.missing_style_urls.extend(compiled.missing_link_urls); + // `compile_component_full` returns Err for anything that breaks the + // output, so what is left does not stop the update. + let Some(parts) = compiled.hmr_update else { + return update; + }; + + // A stylesheet that could not be read is not proof the component has no + // styles, and neither is an empty answer from a source the running + // component was not compiled from: only a complete answer may clear the + // live styles. Content that was read is sent either way. + let complete = update.missing_style_urls.is_empty() && source_matches; + update.styles = + (styles_known && (complete || !parts.styles.is_empty())).then_some(parts.styles); + + update.module = + Some(crate::hmr::generate_hmr_update_module(&crate::hmr::HmrUpdateModuleOptions { + component_id: &update.component_id, + class_name, + template: Some(crate::hmr::HmrTemplateFields { + template_js: &parts.template_js, + decls: parts.decls, + vars: parts.vars, + consts_js: parts.consts_js.as_deref(), + ng_content_selectors_js: parts.ng_content_selectors_js.as_deref(), + }), + styles: update.styles.as_deref(), + declarations_js: Some(parts.declarations_js.as_str()).filter(|d| !d.is_empty()), + })); + update.template_js = parts.template_js; + update } /// Compile a component template and generate ɵcmp/ɵfac definitions. @@ -4821,7 +5270,7 @@ fn compile_component_partial<'a>( let emitter = JsEmitter::new(); FullCompilationResult { - template_js: String::new(), + template_js: None, cmp_js: emitter.emit_expression(&cmp_expr), fac_js: emitter.emit_expression(&fac_expr), declarations_js: String::new(), @@ -4831,10 +5280,11 @@ fn compile_component_partial<'a>( ng_content_selectors: Vec::new(), has_defer_block, implicitly_deferred_imports: Vec::new(), - // Partial mode never parses the template; the linker collects link - // URLs at consumer build time. + // Partial mode only parses the template to report its errors; the + // linker collects link URLs at consumer build time. template_style_urls: Vec::new(), missing_link_urls: Vec::new(), + hmr_update: None, } } @@ -4936,28 +5386,11 @@ fn compile_component_full<'a>( import_map: &ImportMap<'a>, resolved_resources: Option<&ResolvedResources>, deferrable_selector: Option>, - import_elision: &ImportElisionAnalyzer<'_>, + import_elision: Option<&ImportElisionAnalyzer<'_>>, + hmr_update: bool, ) -> Result> { use oxc_allocator::FromIn; - // Partial-mode early branch: skip the entire template pipeline. - // Partial declarations carry the template as a verbatim string and - // let the linker re-parse at consumer build time. Known gap: this also - // means template parse errors (incl. `IncompleteBlockOpen`) go unreported - // for library builds — upstream ngtsc still parses the template in - // declaration mode and surfaces those diagnostics. - if matches!(options.compilation_mode, crate::CompilationMode::Partial) { - return Ok(compile_component_partial( - allocator, - namespace_registry.angular_core_ns().as_str(), - template, - metadata, - &view_queries, - &content_queries, - pool_starting_index, - )); - } - let mut diagnostics = Vec::new(); // Stage 1: Parse HTML @@ -5012,6 +5445,22 @@ fn compile_component_full<'a>( return Err(diagnostics); } + // Partial mode stops here. Its declaration carries the template as a + // verbatim string for the linker to compile at consumer build time, but + // ngtsc still parses it first: its template errors are reported and + // `compilePartial` emits nothing for the class (handler.ts). + if matches!(options.compilation_mode, crate::CompilationMode::Partial) { + return Ok(compile_component_partial( + allocator, + namespace_registry.angular_core_ns().as_str(), + template, + metadata, + &view_queries, + &content_queries, + pool_starting_index, + )); + } + // Capture ng-content selectors from the R3 AST for .d.ts generation let ng_content_selectors: Vec = r3_result.ng_content_selectors.iter().map(|s| s.to_string()).collect(); @@ -5024,7 +5473,7 @@ fn compile_component_full<'a>( // below, like upstream. let mut missing_link_urls = Vec::new(); let template_style_urls: Vec = - r3_result.style_urls.iter().map(|url| url.to_string()).collect(); + r3_result.style_urls.iter().map(ToString::to_string).collect(); if let Some(resources) = resolved_resources { for style_url in &r3_result.style_urls { if let Some(style_contents) = resources.styles.get(style_url.as_str()) { @@ -5047,13 +5496,33 @@ fn compile_component_full<'a>( // Build ingest options from metadata and transform options let component_name_atom = Ident::from_in(metadata.class_name.as_str(), &allocator); - // OXC is a single-file compiler, equivalent to Angular's local compilation mode. - // In local compilation mode, Angular ALWAYS sets hasDirectiveDependencies=true, - // so DomOnly mode is never used for component templates. - // See: angular/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts:1257 + // A full ngtsc build compiles the template in DomOnly mode when the + // component is standalone and nothing in its template scope matches + // (`hasDirectiveDependencies` in + // angular/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts, + // read by `compileComponentFromMetadata`). Its local mode assumes + // dependencies, since it cannot see the imported classes. OXC can prove + // the scope empty only when `imports` and `deferredImports` are absent or + // `[]`: a standalone scope then holds just the component itself + // (`StandaloneComponentScopeReader`), with its selector, or + // `ng-component` when it has none. Anything less certain stays Full, + // which works for every template. // - // Note: DomOnly mode is still used for host bindings (separate code path). - let mode = TemplateCompilationMode::Full; + // Note: DomOnly mode is always used for host bindings (separate code path). + let mode = if metadata.standalone + && metadata.imports_provably_empty + && !options.jit + && options.angular_version.is_none_or(|v| v.supports_dom_only_templates()) + && !super::defer_resolver::template_may_match_selectors( + &r3_result.nodes, + &crate::pipeline::selector::CssSelector::parse( + metadata.selector.as_ref().map_or("ng-component", |s| s.as_str()), + ), + ) { + TemplateCompilationMode::DomOnly + } else { + TemplateCompilationMode::Full + }; // Reject overlap between `imports: [X]` and `deferredImports: [X]` — // a symbol must be either eager OR deferrable, never both. Mirrors @@ -5080,6 +5549,7 @@ fn compile_component_full<'a>( // selectors; skipped under HMR, like ngc. let mut implicitly_deferred_imports = Vec::new(); if let Some(deferrable_selector) = deferrable_selector + && let Some(import_elision) = import_elision && !options.hmr && let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports && raw_imports.entries.iter().all(|entry| matches!(entry, OutputExpression::ReadVar(_))) @@ -5147,7 +5617,11 @@ fn compile_component_full<'a>( // `@defer`. let has_defer_block = super::defer_resolver::template_has_defer_block(&r3_result.nodes); - let defer_resolver_fn = if metadata.deferred_imports.is_empty() { + // An HMR update module cannot carry the resolver: its dynamic `import()`s + // would resolve against the update module's URL, not the component file. + // The update keeps a null dependency function instead (PerComponent mode + // below), as `compile_template_for_hmr` always has. + let defer_resolver_fn = if metadata.deferred_imports.is_empty() || hmr_update { None } else if has_defer_block { super::defer_resolver::build_defer_resolver_expression(allocator, metadata, import_map) @@ -5161,7 +5635,9 @@ fn compile_component_full<'a>( // ingest stage threads `all_deferrable_deps_fn` into each `DeferOp`. // Without a resolver, the existing PerBlock-vs-JIT behavior stays in // place (and `all_deferrable_deps_fn = None`). - let defer_block_deps_emit_mode = if defer_resolver_fn.is_some() { + let defer_block_deps_emit_mode = if defer_resolver_fn.is_some() + || (hmr_update && !metadata.deferred_imports.is_empty() && has_defer_block) + { DeferBlockDepsEmitMode::PerComponent } else if options.jit { DeferBlockDepsEmitMode::PerComponent @@ -5308,6 +5784,28 @@ fn compile_component_full<'a>( declarations_js.push_str(&emitter.emit_statement(decl)); declarations_js.push('\n'); } + + // An HMR update replaces the template fields of the live definition, so + // it takes them from the definition just built: the same text the full + // transform emits. Only the template's declarations go along; the host + // binding's belong to fields the update keeps. + let hmr_update = hmr_update.then(|| HmrUpdateParts { + template_js: cmp_definition_field(&definitions.cmp_definition, "template") + .map(|template| emitter.emit_expression(template)) + .unwrap_or_default(), + declarations_js: declarations_js.clone(), + consts_js: cmp_definition_field(&definitions.cmp_definition, "consts") + .map(|consts| emitter.emit_expression(consts)), + decls: job.root.decl_count.unwrap_or(0), + vars: job.root.vars.unwrap_or(0), + ng_content_selectors_js: cmp_definition_field( + &definitions.cmp_definition, + "ngContentSelectors", + ) + .map(|selectors| emitter.emit_expression(selectors)), + styles: cmp_definition_styles(&definitions.cmp_definition), + }); + // Emit host binding declarations (pooled constants like pure functions) for decl in host_binding_declarations.iter() { declarations_js.push_str(&emitter.emit_statement(decl)); @@ -5315,14 +5813,21 @@ fn compile_component_full<'a>( } // For HMR, we emit the template separately using compile_template_to_js - // The ɵcmp already contains the template function inline - let template_js = - compile_template_to_js(allocator, template, metadata.class_name.as_str(), file_path) - .map_err(|diags| { - let mut all_diags = diagnostics.clone(); - all_diags.extend(diags); - all_diags - })?; + // The ɵcmp already contains the template function inline. Only the HMR + // `template_updates` map reads it, so skip the second template compile + // otherwise. + let template_js = if options.hmr && hmr_update.is_none() { + Some( + compile_template_to_js(allocator, template, metadata.class_name.as_str(), file_path) + .map_err(|diags| { + let mut all_diags = diagnostics.clone(); + all_diags.extend(diags); + all_diags + })?, + ) + } else { + None + }; let cmp_js = emitter.emit_expression(&definitions.cmp_definition); let fac_js = emitter.emit_expression(&definitions.fac_definition); @@ -5363,7 +5868,7 @@ fn compile_component_full<'a>( // Generate class debug info for dev-mode runtime errors // This provides class name, file path, and line number for better error messages let class_debug_info_js = if !options.advanced_optimizations { - use crate::class_debug_info::{R3ClassDebugInfo, compile_class_debug_info}; + use crate::class_debug_info::{R3ClassDebugInfo, compile_class_debug_info, line_number_at}; use crate::output::ast::{OutputExpression, ReadVarExpr}; // Create component type expression (reference to the class) @@ -5372,17 +5877,9 @@ fn compile_component_full<'a>( &allocator, )); - // Compute the 1-indexed line number of the class declaration from its byte offset - let class_line_number = { - let offset = metadata.class_span.start as usize; - let mut line = 1u32; - for &byte in &source.as_bytes()[..offset.min(source.len())] { - if byte == b'\n' { - line += 1; - } - } - line - }; + // The 1-indexed line of the `class` keyword (ngtsc uses `clazz.name.pos`, + // which sits right after it), with TypeScript's line-break rules. + let class_line_number = line_number_at(source, metadata.class_span.start as usize); // Build the debug info with the actual class declaration line number let debug_info = R3ClassDebugInfo::new(component_type, metadata.class_name.clone()) @@ -5428,9 +5925,46 @@ fn compile_component_full<'a>( implicitly_deferred_imports, template_style_urls, missing_link_urls, + hmr_update, }) } +/// The value of `key` in a `ɵɵdefineComponent({...})` call. +fn cmp_definition_field<'b, 'a>( + cmp_definition: &'b OutputExpression<'a>, + key: &str, +) -> Option<&'b OutputExpression<'a>> { + let OutputExpression::InvokeFunction(call) = cmp_definition else { return None }; + let Some(OutputExpression::LiteralMap(config)) = call.args.first() else { return None }; + config + .entries + .iter() + .find(|entry| !entry.is_spread && entry.key.as_str() == key) + .map(|entry| &entry.value) +} + +/// The finished CSS strings in a `ɵɵdefineComponent({...})` call's `styles` +/// (encapsulated and minified as configured, empty ones dropped). +fn cmp_definition_styles(cmp_definition: &OutputExpression<'_>) -> Vec { + use crate::output::ast::LiteralValue; + let Some(OutputExpression::LiteralArray(styles)) = + cmp_definition_field(cmp_definition, "styles") + else { + return Vec::new(); + }; + styles + .entries + .iter() + .filter_map(|style| match style { + OutputExpression::Literal(literal) => match &literal.value { + LiteralValue::String(style) => Some(style.to_string()), + _ => None, + }, + _ => None, + }) + .collect() +} + /// Resolve template content from inline or external source. /// /// Precedence matches Angular's AOT compiler (`parseTemplateDeclaration` in @@ -5917,6 +6451,8 @@ pub fn compile_template_for_hmr<'a>( // Extract styles from