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 9e8d64d98..1a3c8d74e 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -32,6 +32,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use super::defer_resolver::{ClassUse, decorated_class_selector}; use crate::directive::{ImportValueResolver, StringConsts}; +mod ng_module_scope; + /// `(file, exported_name, local_name, implicit_standalone)` -> how a /// template uses the class (see `Inner::selector_memo`). type SelectorMemo = FxHashMap<(String, String, String, bool), Option>; @@ -102,6 +104,8 @@ struct Inner { /// Handle to this state, for the resolvers of the files in `parsed` /// (`parse_file` only sees `&self`). this: Weak, + /// Memoized NgModule scope reads (see [`ng_module_scope`]). + scope: ng_module_scope::ScopeState, } /// A file parsed once per analyzer: its program, and the consts the @@ -250,6 +254,7 @@ impl CrossFileAnalyzer { evaluating_values: RefCell::new(FxHashSet::default()), value_dependencies: RefCell::new(FxHashSet::default()), this: this.clone(), + scope: ng_module_scope::ScopeState::default(), }), } } diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs new file mode 100644 index 000000000..26dd6a0c6 --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs @@ -0,0 +1,1107 @@ +//! Static NgModule scopes for partial compilation (#655). +//! +//! A partial declaration of a component an NgModule declares carries no +//! `dependencies`: ngtsc lists the module's compilation scope there, which it +//! reads from the whole program. Oxc compiles one file at a time, and a +//! component's file doesn't name the module that declares it. The module's +//! file does, so it calls `ɵɵsetComponentScope` for each component it +//! declares, with the scope read here: the `@NgModule` of a source file, or +//! the `ɵmod` type of a declaration file, like ngtsc's `LocalModuleScopeRegistry` +//! and `DtsMetadataReader`. +//! +//! Anything that can't be read statically gives `None`, and the caller emits +//! what it did before. + +use std::cell::{OnceCell, RefCell}; +use std::path::Path; +use std::rc::Rc; + +use oxc_ast::ast::{ + ArrayExpressionElement, Class, ClassElement, Declaration, ExportDefaultDeclarationKind, + Expression, ImportDeclarationSpecifier, ObjectPropertyKind, Program, PropertyKey, Statement, + TSLiteral, TSTupleElement, TSType, TSTypeName, +}; +use oxc_span::GetSpan; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{CrossFileAnalyzer, Inner, MAX_EXPORT_CHAIN}; +use crate::component::metadata::{ComponentScope, ImportedName, ScopeEntry, ScopeKind}; +use crate::directive::{Evaluator, StringConsts, find_angular_class_decorator}; + +/// A top-level declaration: the file declaring it and its name there +/// (`"default"` for an anonymous `export default class`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Decl { + file: String, + name: String, +} + +/// A reference to a class as some file writes it: `name`, `namespace.name`, +/// or `name.method(...)`, a static method returning `ModuleWithProviders`. +#[derive(Debug, Clone)] +struct RawRef { + file: String, + namespace: Option, + name: String, + method: Option, +} + +/// A class a reference resolves to, and the non-relative module specifier +/// it was imported through, if any (ngtsc's `bestGuessOwningModule`): the +/// specifier the compiled file imports it from. +#[derive(Debug, Clone)] +struct ClassRef { + decl: Decl, + owner: Option, +} + +/// What a declaration is to a module scope. +#[derive(Debug)] +enum Declared { + Directive { + selector: Option, + is_component: bool, + }, + Pipe { + name: Option, + }, + NgModule { + declarations: Vec, + imports: Vec, + exports: Vec, + }, + /// A `const` list of references, spread or named in a module's lists. + List(Vec), +} + +/// A directive or pipe of a scope, before it's named for the compiled file. +#[derive(Debug, Clone)] +struct ScopeItem { + class: ClassRef, + kind: ScopeKind, +} + +/// How a file exports a name. +#[derive(Debug)] +enum Export { + /// `export class X`, `export { Y as X }`: the local binding. + Local(String), + /// `export { Y as X } from 'spec'`. + From { spec: String, name: String }, +} + +/// A top-level binding of a file. +#[derive(Debug)] +enum Local { + /// A class or variable the file declares. + Declared, + /// An import of `imported` from `spec`; `None` for `import * as`. + Import { spec: String, imported: Option }, +} + +/// A file's exports and top-level bindings. +#[derive(Debug, Default)] +struct FileExports { + /// Export names, in source order. + names: Vec, + named: FxHashMap, + /// The specifiers of `export * from`. + stars: Vec, + locals: FxHashMap, + /// The specifiers of its imports, in source order. + import_specs: Vec, + /// The specifiers of the imports and re-exports that load a module at + /// runtime: not those of types only. + loads: Vec, +} + +/// Per NgModule, and the module it was imported through, its exported scope. +type ExportedScopes = FxHashMap<(Decl, Option), Option>>; + +/// Memoized reads of [`Inner`]'s scope queries. +#[derive(Default)] +pub(super) struct ScopeState { + /// The analyzer's resolver, reading the `types` of packages. + resolver: OnceCell, + files: RefCell>>, + declared: RefCell>>>, + exported: RefCell, + /// Modules whose exported scope is being read: a cycle gives `None`. + visiting: RefCell>, + /// Per module file, the name it exports each declaration by. + export_names: RefCell>>>, + /// The declaration each `(file, name)` is exported as. + found_exports: RefCell>>, + /// `const` lists being flattened: a list that spreads itself has none. + flattening: RefCell>, +} + +impl CrossFileAnalyzer { + /// The compilation scope of each component the `@NgModule` class + /// `class_name` of `file` declares: the directives and pipes the + /// module's imports export, then its own declarations (ngtsc's + /// `LocalModuleScopeRegistry`). `source` is the text of `file` being + /// compiled. `None` when part of it can't be read statically. + pub(crate) fn ng_module_component_scopes( + &self, + file: &Path, + source: &str, + class_name: &str, + ) -> Option> { + let file = self.inner.set_compiled_source(file, source); + let module = + ClassRef { decl: Decl { file: file.clone(), name: class_name.into() }, owner: None }; + let declared = self.inner.declared(&module.decl)?; + let Declared::NgModule { declarations, imports, .. } = &*declared else { return None }; + let inner = &self.inner; + + let mut scope = Vec::new(); + for (class, declared) in inner.resolve_refs(imports, None, 0)? { + match &*declared { + Declared::NgModule { exports, .. } => { + scope.extend(inner.exported_scope(&class, exports)?.iter().cloned()); + } + // A standalone class imported directly. + _ => scope.push(ScopeItem { kind: scope_kind(&declared)?, class }), + } + } + let mut components = Vec::new(); + for (class, declared) in inner.resolve_refs(declarations, None, 0)? { + let kind = scope_kind(&declared)?; + if matches!(kind, ScopeKind::Directive { is_component: true, .. }) { + components.push(class.clone()); + } + scope.push(ScopeItem { class, kind }); + } + let entries = inner.named_entries(dedupe(scope), &file)?; + let mut scopes = Vec::new(); + for class in &components { + let component = inner.imported_name(class, &file)?; + // The call reads the component class when this file runs. + if inner.loads_back(&file, &component, &class.decl) { + continue; + } + scopes.push(ComponentScope { component, entries: entries.clone() }); + } + Some(scopes) + } + + /// For each of the `imports` (`namespace.name` or `name` in `file`) of the + /// standalone `component`, the directives and pipes it adds when it's an + /// NgModule, or `None` when it's none or its scope can't be read + /// statically. A class is listed once: an NgModule leaves out what + /// another import already is, or an earlier NgModule exports. + pub(crate) fn standalone_import_scopes( + &self, + file: &Path, + source: &str, + component: &str, + imports: &[(Option<&str>, &str)], + ) -> Vec>> { + let file = self.inner.set_compiled_source(file, source); + // The classes of the file declared after the component, which its + // definition can't read when it's created. + let declared_later: FxHashSet = self + .inner + .parsed_file(&file) + .map(|parsed| { + parsed.with(|_, program, _| { + let start = find_class(program, component).map_or(u32::MAX, |c| c.span.start); + program + .body + .iter() + .filter(|stmt| stmt.span().start > start) + .filter_map(|stmt| match stmt { + Statement::ClassDeclaration(class) => class.id.as_ref(), + Statement::ExportDeclaration(decl) => match &decl.declaration { + Declaration::ClassDeclaration(class) => class.id.as_ref(), + _ => None, + }, + Statement::ExportDefaultDeclaration(decl) => match &decl.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + class.id.as_ref() + } + _ => None, + }, + _ => None, + }) + .map(|id| id.name.to_string()) + .collect() + }) + }) + .unwrap_or_default(); + let resolved: Vec<_> = imports + .iter() + .map(|(namespace, name)| { + let raw = RawRef { + file: file.clone(), + namespace: namespace.map(Into::into), + name: (*name).into(), + method: None, + }; + let class = self.inner.resolve_ref(&raw, None, 0)?; + let declared = self.inner.declared(&class.decl)?; + Some((class, declared)) + }) + .collect(); + let mut listed: FxHashSet = + resolved.iter().flatten().map(|(class, _)| class.decl.clone()).collect(); + resolved + .into_iter() + .map(|import| { + let (class, declared) = import?; + let Declared::NgModule { exports, .. } = &*declared else { return None }; + let scope = self.inner.exported_scope(&class, exports)?; + let added: Vec<_> = scope + .iter() + .filter(|item| !listed.contains(&item.class.decl)) + .cloned() + .collect(); + let mut entries = self.inner.named_entries(added.clone(), &file)?; + // The component's definition reads them when it's created. + for (entry, item) in entries.iter_mut().zip(&added) { + entry.is_forward_reference = match &entry.name { + ImportedName::Local(name) => declared_later.contains(name), + ImportedName::Module { .. } => { + self.inner.loads_back(&file, &entry.name, &item.class.decl) + } + }; + } + // A pipe the linker can't match without a name. + if entries.iter().any(|entry| matches!(entry.kind, ScopeKind::Pipe { name: None })) + { + return None; + } + listed.extend(added.into_iter().map(|item| item.class.decl)); + Some(entries) + }) + .collect() + } +} + +/// `items` with each class once, where it first appears. +fn dedupe(items: Vec) -> Vec { + let mut seen = FxHashSet::default(); + items.into_iter().filter(|item| seen.insert(item.class.decl.clone())).collect() +} + +/// The scope kind of a directive or pipe. +fn scope_kind(declared: &Declared) -> Option { + match declared { + Declared::Directive { selector, is_component } => { + Some(ScopeKind::Directive { selector: selector.clone(), is_component: *is_component }) + } + Declared::Pipe { name } => Some(ScopeKind::Pipe { name: name.clone() }), + Declared::NgModule { .. } | Declared::List(_) => None, + } +} + +/// `file` as the resolver writes a real path: `/tmp` is a link on macOS, +/// and on Windows without the `\\?\` prefix `canonicalize` adds. +fn real_path(file: &Path) -> String { + let path = std::fs::canonicalize(file) + .unwrap_or_else(|_| file.to_path_buf()) + .to_string_lossy() + .into_owned(); + if let Some(share) = path.strip_prefix(r"\\?\UNC\") { + format!(r"\\{share}") + } else if let Some(local) = path.strip_prefix(r"\\?\") { + local.to_string() + } else { + path + } +} + +/// Whether `file` is in a package rather than the workspace. +fn in_node_modules(file: &str) -> bool { + Path::new(file).components().any(|part| part.as_os_str() == "node_modules") +} + +/// Whether `spec` names a module by path rather than by package. +fn is_relative(spec: &str) -> bool { + spec.starts_with('.') || Path::new(spec).is_absolute() +} + +impl Inner { + /// Parse `source` as the text of `file`, the file being compiled, in + /// place of what's on disk. Returns the key it's read by. + fn set_compiled_source(&self, file: &Path, source: &str) -> String { + let key = real_path(file); + let current = self + .parsed + .borrow() + .get(&key) + .map(|parsed| parsed.with(|_, p, _| p.source_text == source)); + if current != Some(true) { + let parsed = Rc::new(self.parse_file(&key, source)); + self.parsed.borrow_mut().insert(key.clone(), parsed); + self.scope.files.borrow_mut().remove(&key); + } + key + } + + /// Resolve `spec` the way TypeScript does from `from_file`: `.d.ts` + /// files for packages, `.ts` for `./x.js`. + fn resolve_types(&self, from_file: &str, spec: &str) -> Option { + let resolver = self.scope.resolver.get_or_init(|| { + // A package's `types` export, as `tsc` reads it. + let options = oxc_resolver::ResolveOptions { + condition_names: vec!["types".into(), "import".into()], + ..self.resolver.options().clone() + }; + self.resolver.clone_with_options(options) + }); + let resolved = resolver.resolve_dts(from_file, spec).ok()?; + let file = resolved.path().to_string_lossy().into_owned(); + // A package is no file the build watches. + if !in_node_modules(&file) { + self.value_dependencies.borrow_mut().insert(file.clone()); + } + Some(file) + } + + /// Whether the class `decl`, which `file` refers to as `name`, can be + /// read before it exists when `file` runs: whether the file it's + /// imported from or the one that declares it loads `file` back. + fn loads_back(&self, file: &str, name: &ImportedName, decl: &Decl) -> bool { + let loaded = match name { + ImportedName::Local(_) => None, + ImportedName::Module { specifier, .. } => self.resolve_types(file, specifier), + }; + loaded.iter().chain([&decl.file]).any(|from| { + from != file && real_path(Path::new(from)) != file && self.imports_file(from, file) + }) + } + + /// Whether loading the workspace file `from` loads `target`: whether it + /// imports or re-exports it, directly or through other workspace files. + fn imports_file(&self, from: &str, target: &str) -> bool { + let mut seen = FxHashSet::default(); + let mut pending = vec![from.to_string()]; + while let Some(file) = pending.pop() { + if in_node_modules(&file) || !seen.insert(file.clone()) { + continue; + } + // A path through a tsconfig alias can be spelled differently + // (a Windows short name, a link). + if file == target || real_path(Path::new(&file)) == target { + return true; + } + let Some(exports) = self.file_exports(&file) else { continue }; + for spec in &exports.loads { + pending.extend(self.resolve_types(&file, spec)); + } + } + false + } + + /// The exports and top-level bindings of `file`. + fn file_exports(&self, file: &str) -> Option> { + if let Some(exports) = self.scope.files.borrow().get(file) { + return Some(Rc::clone(exports)); + } + let parsed = self.parsed_file(file)?; + let exports = Rc::new(parsed.with(|_, program, _| collect_file_exports(program))); + self.scope.files.borrow_mut().insert(file.to_string(), Rc::clone(&exports)); + Some(exports) + } + + /// The declaration `file` exports as `name`, following re-exports. + fn find_export(&self, file: &str, name: &str) -> Option { + let key = (file.to_string(), name.to_string()); + if let Some(found) = self.scope.found_exports.borrow().get(&key) { + return found.clone(); + } + let found = self.search_export(file, name, 0, &mut FxHashSet::default()); + self.scope.found_exports.borrow_mut().insert(key, found.clone()); + found + } + + /// [`Self::find_export`], looking at each `(file, name)` once, as + /// TypeScript does: an `export *` cycle ends, and no path is followed + /// twice. + fn search_export( + &self, + file: &str, + name: &str, + depth: u16, + seen: &mut FxHashSet<(String, String)>, + ) -> Option { + if depth > MAX_EXPORT_CHAIN || !seen.insert((file.to_string(), name.to_string())) { + return None; + } + let exports = self.file_exports(file)?; + match exports.named.get(name) { + Some(Export::Local(local)) => match exports.locals.get(local)? { + Local::Declared => Some(Decl { file: file.to_string(), name: local.clone() }), + Local::Import { spec, imported: Some(imported) } => { + self.search_export(&self.resolve_types(file, spec)?, imported, depth + 1, seen) + } + Local::Import { imported: None, .. } => None, + }, + Some(Export::From { spec, name }) => { + self.search_export(&self.resolve_types(file, spec)?, name, depth + 1, seen) + } + None if name == "default" => None, + None => exports.stars.iter().find_map(|spec| { + self.search_export(&self.resolve_types(file, spec)?, name, depth + 1, seen) + }), + } + } + + /// The class `raw` names, reached through the module `owner` names. + fn resolve_ref(&self, raw: &RawRef, owner: Option<&str>, depth: u16) -> Option { + if depth > MAX_EXPORT_CHAIN { + return None; + } + let exports = self.file_exports(&raw.file)?; + let binding = raw.namespace.as_deref().unwrap_or(&raw.name); + let (decl, owner) = match (exports.locals.get(binding)?, &raw.namespace) { + (Local::Declared, None) => { + (Decl { file: raw.file.clone(), name: raw.name.clone() }, owner.map(Into::into)) + } + (Local::Import { spec, imported }, namespace) => { + let name = match (imported, namespace) { + (Some(imported), None) => imported.as_str(), + (None, Some(_)) => raw.name.as_str(), + _ => return None, + }; + let decl = self.find_export(&self.resolve_types(&raw.file, spec)?, name)?; + let owner = + if is_relative(spec) { owner.map(Into::into) } else { Some(spec.clone()) }; + (decl, owner) + } + (Local::Declared, Some(_)) => return None, + }; + let class = ClassRef { decl, owner }; + match &raw.method { + None => Some(class), + Some(method) => { + let module = self.module_with_providers(&class.decl, method)?; + self.resolve_ref(&module, class.owner.as_deref(), depth + 1) + } + } + } + + /// The NgModule the static method `method` of the class `decl` returns + /// a `ModuleWithProviders` of: its return type's argument, or the + /// `ngModule` of the object it returns. + fn module_with_providers(&self, decl: &Decl, method: &str) -> Option { + let parsed = self.parsed_file(&decl.file)?; + parsed.with(|_, program, _| { + let class = find_class(program, &decl.name)?; + let function = class.body.body.iter().find_map(|element| match element { + ClassElement::MethodDefinition(m) + if m.r#static && m.key.static_name().is_some_and(|key| key == method) => + { + Some(&m.value) + } + _ => None, + })?; + if let Some(return_type) = &function.return_type + && let TSType::TSTypeReference(reference) = &return_type.type_annotation + && type_name_ends_with(&reference.type_name, "ModuleWithProviders") + { + let argument = reference.type_arguments.as_ref()?.params.first()?; + let TSType::TSTypeReference(module) = argument else { return None }; + return type_name_ref(&decl.file, &module.type_name); + } + let body = function.body.as_ref()?; + let returned = body.statements.iter().rev().find_map(|stmt| match stmt { + Statement::ReturnStatement(ret) => ret.argument.as_ref(), + _ => None, + })?; + let mut refs = Vec::new(); + collect_refs(returned, &decl.file, &mut refs, 0)?; + match <[RawRef; 1]>::try_from(refs) { + Ok([module]) => Some(module), + Err(_) => None, + } + }) + } + + /// What the declaration `decl` is to a module scope. + fn declared(&self, decl: &Decl) -> Option> { + if let Some(declared) = self.scope.declared.borrow().get(decl) { + return declared.clone(); + } + let declared = self.parsed_file(&decl.file).and_then(|parsed| { + parsed.with(|_, program, consts| read_declared(program, consts, decl)).map(Rc::new) + }); + self.scope.declared.borrow_mut().insert(decl.clone(), declared.clone()); + declared + } + + /// The classes `refs` name, with `const` lists flattened. + fn resolve_refs( + &self, + refs: &[RawRef], + owner: Option<&str>, + depth: u16, + ) -> Option)>> { + if depth > MAX_EXPORT_CHAIN { + return None; + } + let mut out = Vec::new(); + for raw in refs { + let class = self.resolve_ref(raw, owner, 0)?; + let declared = self.declared(&class.decl)?; + if let Declared::List(items) = &*declared { + if !self.scope.flattening.borrow_mut().insert(class.decl.clone()) { + return None; + } + let items = self.resolve_refs(items, class.owner.as_deref(), depth + 1); + self.scope.flattening.borrow_mut().remove(&class.decl); + out.extend(items?); + } else { + out.push((class, declared)); + } + } + Some(out) + } + + /// The directives and pipes the NgModule `module` exports, with those of + /// the modules it exports. + fn exported_scope(&self, module: &ClassRef, exports: &[RawRef]) -> Option> { + let key = (module.decl.clone(), module.owner.clone()); + if let Some(scope) = self.scope.exported.borrow().get(&key) { + return scope.clone(); + } + if !self.scope.visiting.borrow_mut().insert(module.decl.clone()) { + return None; + } + let scope = (|| { + let mut scope = Vec::new(); + for (class, declared) in self.resolve_refs(exports, module.owner.as_deref(), 0)? { + match &*declared { + Declared::NgModule { exports, .. } => { + scope.extend(self.exported_scope(&class, exports)?.iter().cloned()); + } + _ => scope.push(ScopeItem { kind: scope_kind(&declared)?, class }), + } + } + Some(Rc::from(dedupe(scope))) + })(); + self.scope.visiting.borrow_mut().remove(&module.decl); + self.scope.exported.borrow_mut().insert(key, scope.clone()); + scope + } + + /// `items` named for the compiled `file`. + fn named_entries(&self, items: Vec, file: &str) -> Option> { + items + .into_iter() + .map(|item| { + Some(ScopeEntry { + name: self.imported_name(&item.class, file)?, + kind: item.kind, + is_forward_reference: false, + }) + }) + .collect() + } + + /// How the compiled `file` names `class`: by its name when `file` + /// declares it, else by its export from the module it was imported + /// through, or from its own file by a relative path (ngtsc's + /// `AbsoluteModuleStrategy` and `RelativePathStrategy`). + fn imported_name(&self, class: &ClassRef, file: &str) -> Option { + if class.decl.file == file { + return (class.decl.name != "default") + .then(|| ImportedName::Local(class.decl.name.clone())); + } + if let Some(specifier) = &class.owner { + let name = self + .resolve_types(file, specifier) + .and_then(|entry| self.export_name(&entry, &class.decl)); + if let Some(name) = name { + return Some(ImportedName::Module { specifier: specifier.clone(), name }); + } + // A workspace library's source, by path, when its entry point + // doesn't export the class. + if in_node_modules(&class.decl.file) { + return None; + } + } + let name = self.export_name(&class.decl.file, &class.decl)?; + // The file's own import of that file, as it's written + // (`./dep.js`), else a relative path like ngtsc's. + let imported = self + .file_exports(file)? + .import_specs + .iter() + .find(|spec| { + is_relative(spec) + && self.resolve_types(file, spec).as_ref() == Some(&class.decl.file) + }) + .cloned(); + if let Some(specifier) = imported { + return Some(ImportedName::Module { specifier, name }); + } + let from_dir = Path::new(file).parent()?; + let specifier = self.make_relative_path(from_dir, Path::new(&class.decl.file))?; + // The JavaScript file TypeScript writes for it: `./a.mts` is loaded + // as `./a.mjs`. + let specifier = [ + (".d.mts", ".mjs"), + (".mts", ".mjs"), + (".d.cts", ".cjs"), + (".cts", ".cjs"), + (".d", ""), + ] + .iter() + .find_map(|(ts, js)| specifier.strip_suffix(ts).map(|path| format!("{path}{js}"))) + .unwrap_or(specifier); + Some(ImportedName::Module { specifier, name }) + } + + /// The name the module `file` exports `decl` by, preferring the + /// declaration's own name, like ngtsc's `AbsoluteModuleStrategy`. + fn export_name(&self, file: &str, decl: &Decl) -> Option { + let cached = self.scope.export_names.borrow().get(file).cloned(); + if let Some(names) = cached { + return names.get(decl).cloned(); + } + let mut all = Vec::new(); + self.all_export_names(file, &mut FxHashSet::default(), &mut all, 0); + let mut names: FxHashMap = FxHashMap::default(); + for name in all { + let Some(found) = self.find_export(file, &name) else { continue }; + if found.name == name || !names.contains_key(&found) { + names.insert(found, name); + } + } + let found = names.get(decl).cloned(); + self.scope.export_names.borrow_mut().insert(file.to_string(), Rc::new(names)); + found + } + + /// Every name `file` exports, with those of its `export *` modules. + fn all_export_names( + &self, + file: &str, + visited: &mut FxHashSet, + out: &mut Vec, + depth: u16, + ) { + if depth > MAX_EXPORT_CHAIN || !visited.insert(file.to_string()) { + return; + } + let Some(exports) = self.file_exports(file) else { return }; + out.extend(exports.names.iter().cloned()); + for spec in &exports.stars { + if let Some(target) = self.resolve_types(file, spec) { + let start = out.len(); + self.all_export_names(&target, visited, out, depth + 1); + // `export *` leaves out `default`. + let mut index = start; + while index < out.len() { + if out[index] == "default" { + out.remove(index); + } else { + index += 1; + } + } + } + } + } +} + +/// The exports and top-level bindings of `program`. Type-only ones are left +/// out: no scope names an interface. +fn collect_file_exports(program: &Program<'_>) -> FileExports { + let mut exports = FileExports::default(); + let export = |exports: &mut FileExports, name: &str, how: Export| { + if !exports.named.contains_key(name) { + exports.names.push(name.to_string()); + exports.named.insert(name.to_string(), how); + } + }; + let declare = |exports: &mut FileExports, declaration: &Declaration<'_>| -> Vec { + let mut names = Vec::new(); + match declaration { + Declaration::ClassDeclaration(class) => { + if let Some(id) = &class.id { + names.push(id.name.to_string()); + } + } + Declaration::VariableDeclaration(vars) => { + for var in &vars.declarations { + for id in var.id.get_binding_identifiers() { + names.push(id.name.to_string()); + } + } + } + _ => {} + } + for name in &names { + exports.locals.insert(name.clone(), Local::Declared); + } + names + }; + for stmt in &program.body { + match stmt { + Statement::ImportDeclaration(import) => { + let Some(spec) = import.source.value.as_str() else { continue }; + exports.import_specs.push(spec.to_string()); + // `import { type A }` is erased; `import {} from` and + // `import 'x'` load the module. + let types_only = import.import_kind.is_type() + || import.specifiers.as_ref().is_some_and(|specifiers| { + !specifiers.is_empty() + && specifiers.iter().all(|specifier| { + matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(s) + if s.import_kind.is_type()) + }) + }); + if !types_only { + exports.loads.push(spec.to_string()); + } + for specifier in import.specifiers.iter().flatten() { + let (local, imported) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(s) => { + (s.local.name.to_string(), Some(s.imported.name().to_string())) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => { + (s.local.name.to_string(), Some("default".to_string())) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + (s.local.name.to_string(), None) + } + }; + exports + .locals + .insert(local, Local::Import { spec: spec.to_string(), imported }); + } + } + Statement::ExportDeclaration(decl) => { + for name in declare(&mut exports, &decl.declaration) { + export(&mut exports, &name, Export::Local(name.clone())); + } + } + Statement::ExportNamedDeclaration(decl) if !decl.export_kind.is_type() => { + for spec in decl.specifiers.iter().filter(|spec| !spec.export_kind.is_type()) { + let local = spec.local.name().to_string(); + export(&mut exports, &spec.exported.name(), Export::Local(local)); + } + } + Statement::ExportFromDeclaration(decl) if !decl.export_kind.is_type() => { + let Some(source) = decl.source.value.as_str() else { continue }; + if decl.specifiers.is_empty() + || decl.specifiers.iter().any(|spec| !spec.export_kind.is_type()) + { + exports.loads.push(source.to_string()); + } + for spec in decl.specifiers.iter().filter(|spec| !spec.export_kind.is_type()) { + let how = Export::From { + spec: source.to_string(), + name: spec.local.name().to_string(), + }; + export(&mut exports, &spec.exported.name(), how); + } + } + Statement::ExportAllDeclaration(decl) if !decl.export_kind.is_type() => { + if let Some(source) = decl.source.value.as_str() { + exports.loads.push(source.to_string()); + // `export * as ns` names a namespace, which no scope + // reads a class through. + if decl.exported.is_none() { + exports.stars.push(source.to_string()); + } + } + } + Statement::ExportDefaultDeclaration(decl) => match &decl.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + let name = + class.id.as_ref().map_or("default".to_string(), |id| id.name.to_string()); + exports.locals.insert(name.clone(), Local::Declared); + export(&mut exports, "default", Export::Local(name)); + } + ExportDefaultDeclarationKind::Identifier(id) => { + export(&mut exports, "default", Export::Local(id.name.to_string())); + } + _ => {} + }, + _ => { + if let Some(declaration) = stmt.as_declaration() { + declare(&mut exports, declaration); + } + } + } + } + exports +} + +/// The top-level class `program` declares as `name` (`"default"` for an +/// anonymous `export default class`). +fn find_class<'p, 'a>(program: &'p Program<'a>, name: &str) -> Option<&'p Class<'a>> { + program.body.iter().find_map(|stmt| { + let class = match stmt { + Statement::ClassDeclaration(class) => class, + Statement::ExportDeclaration(decl) => match &decl.declaration { + Declaration::ClassDeclaration(class) => class, + _ => return None, + }, + Statement::ExportDefaultDeclaration(decl) => match &decl.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => class, + _ => return None, + }, + _ => return None, + }; + let matches = match &class.id { + Some(id) => id.name == name, + None => name == "default", + }; + matches.then_some(&**class) + }) +} + +/// What the declaration `decl` of `program` is to a module scope: a class +/// by its decorator, or by the `ɵcmp`/`ɵdir`/`ɵpipe`/`ɵmod` type a +/// declaration file gives it, or a `const` list of references. +fn read_declared<'a>( + program: &'a Program<'a>, + consts: &StringConsts<'a>, + decl: &Decl, +) -> Option { + if let Some(class) = find_class(program, &decl.name) { + return read_declaration_type(class, &decl.file) + .or_else(|| read_decorator(class, consts, &decl.file)); + } + // `const LIST = [...]` + program.body.iter().find_map(|stmt| { + let declaration = match stmt { + Statement::ExportDeclaration(decl) => &decl.declaration, + _ => stmt.as_declaration()?, + }; + let Declaration::VariableDeclaration(vars) = declaration else { return None }; + let var = vars.declarations.iter().find(|var| { + var.id.get_binding_identifier().is_some_and(|id| id.name == decl.name.as_str()) + })?; + if !vars.kind.is_const() { + return None; + } + let mut refs = Vec::new(); + collect_refs(var.init.as_ref()?, &decl.file, &mut refs, 0)?; + Some(Declared::List(refs)) + }) +} + +/// The scope a declaration file's `static ɵcmp`/`ɵdir`/`ɵpipe`/`ɵmod` +/// type gives `class`. +fn read_declaration_type(class: &Class<'_>, file: &str) -> Option { + class.body.body.iter().find_map(|element| { + let ClassElement::PropertyDefinition(property) = element else { return None }; + if !property.r#static { + return None; + } + let PropertyKey::StaticIdentifier(key) = &property.key else { return None }; + let TSType::TSTypeReference(reference) = + &property.type_annotation.as_ref()?.type_annotation + else { + return None; + }; + let arguments = &reference.type_arguments.as_ref()?.params; + let string_at = |index: usize| match arguments.get(index) { + Some(TSType::TSLiteralType(literal)) => match &literal.literal { + TSLiteral::StringLiteral(s) => s.value.as_str().map(Into::into), + _ => None, + }, + _ => None, + }; + match key.name.as_str() { + "ɵcmp" => Some(Declared::Directive { selector: string_at(1), is_component: true }), + "ɵdir" => Some(Declared::Directive { selector: string_at(1), is_component: false }), + "ɵpipe" => Some(Declared::Pipe { name: string_at(1) }), + "ɵmod" => { + let list = |index: usize| type_list(arguments.get(index)?, file); + Some(Declared::NgModule { + declarations: list(1)?, + imports: list(2)?, + exports: list(3)?, + }) + } + _ => None, + } + }) +} + +/// The classes a `[typeof A, typeof i1.B]` tuple type names; `never` names +/// none. +fn type_list(ty: &TSType<'_>, file: &str) -> Option> { + match ty { + TSType::TSNeverKeyword(_) => Some(Vec::new()), + TSType::TSTupleType(tuple) => tuple + .element_types + .iter() + .map(|element| { + let TSTupleElement::TSTypeQuery(query) = element else { return None }; + type_name_ref(file, query.expr_name.as_ts_type_name()?) + }) + .collect(), + _ => None, + } +} + +/// The class a type name refers to: `A`, or `i1.A` of a namespace import. +fn type_name_ref(file: &str, name: &TSTypeName<'_>) -> Option { + let (namespace, name) = match name { + TSTypeName::IdentifierReference(id) => (None, id.name.to_string()), + TSTypeName::QualifiedName(qualified) => match &qualified.left { + TSTypeName::IdentifierReference(ns) => { + (Some(ns.name.to_string()), qualified.right.name.to_string()) + } + _ => return None, + }, + TSTypeName::ThisExpression(_) => return None, + }; + Some(RawRef { file: file.into(), namespace, name, method: None }) +} + +/// Whether a type name is `name` or `ns.name`. +fn type_name_ends_with(type_name: &TSTypeName<'_>, name: &str) -> bool { + match type_name { + TSTypeName::IdentifierReference(id) => id.name == name, + TSTypeName::QualifiedName(qualified) => qualified.right.name == name, + TSTypeName::ThisExpression(_) => false, + } +} + +/// The scope the Angular decorator of a source `class` gives it. +fn read_decorator<'a>( + class: &'a Class<'a>, + consts: &StringConsts<'a>, + file: &str, +) -> Option { + let (decorator, kind) = + ["Component", "Directive", "Pipe", "NgModule"].iter().find_map(|kind| { + find_angular_class_decorator(&class.decorators, kind, consts).map(|d| (d, *kind)) + })?; + let Expression::CallExpression(call) = &decorator.expression else { return None }; + let object = match call.arguments.first() { + None => None, + Some(argument) => match argument.as_expression()?.get_inner_expression() { + Expression::ObjectExpression(object) => Some(object), + _ => return None, + }, + }; + // A spread or a computed key can set any field. + let mut fields: Vec<(std::borrow::Cow<'_, str>, &Expression<'a>)> = Vec::new(); + for property in object.iter().flat_map(|object| &object.properties) { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + fields.push((property.key.static_name()?.into_cow_str()?, &property.value)); + } + let field = |key: &str| fields.iter().rev().find(|(name, _)| name == key).map(|(_, v)| *v); + let string = |key: &str| { + field(key).and_then(|value| Evaluator::new(consts).evaluate(value).as_str().map(Into::into)) + }; + match kind { + "Component" => { + Some(Declared::Directive { selector: string("selector"), is_component: true }) + } + "Directive" => { + Some(Declared::Directive { selector: string("selector"), is_component: false }) + } + "Pipe" => Some(Declared::Pipe { name: string("name") }), + _ => { + let list = |key: &str| -> Option> { + let mut refs = Vec::new(); + if let Some(value) = field(key) { + collect_refs(value, file, &mut refs, 0)?; + } + Some(refs) + }; + Some(Declared::NgModule { + declarations: list("declarations")?, + imports: list("imports")?, + exports: list("exports")?, + }) + } + } +} + +/// Collect the references `expr` lists into `out`: a class (`A`, `ns.A`, +/// `forwardRef(() => A)`), a `ModuleWithProviders` (`A.forRoot()`, +/// `{ ngModule: A }`), or an array of those, spread or nested. `None` for +/// anything else. +fn collect_refs( + expr: &Expression<'_>, + file: &str, + out: &mut Vec, + depth: u16, +) -> Option<()> { + if depth > MAX_EXPORT_CHAIN { + return None; + } + let raw = |namespace: Option<&str>, name: &str, method: Option<&str>| RawRef { + file: file.into(), + namespace: namespace.map(Into::into), + name: name.into(), + method: method.map(Into::into), + }; + match expr.get_inner_expression() { + Expression::ArrayExpression(array) => { + for element in &array.elements { + match element { + ArrayExpressionElement::SpreadElement(spread) => { + collect_refs(&spread.argument, file, out, depth + 1)?; + } + ArrayExpressionElement::Elision(_) => return None, + _ => collect_refs(element.as_expression()?, file, out, depth + 1)?, + } + } + } + Expression::Identifier(id) => out.push(raw(None, &id.name, None)), + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(object) = member.object.get_inner_expression() else { + return None; + }; + out.push(raw(Some(&object.name), &member.property.name, None)); + } + Expression::CallExpression(call) => match call.callee.get_inner_expression() { + // `forwardRef(() => A)` + Expression::Identifier(callee) if callee.name == "forwardRef" => { + let returned = match call.arguments.first()?.as_expression()?.get_inner_expression() + { + Expression::ArrowFunctionExpression(arrow) => arrow.get_expression()?, + Expression::FunctionExpression(function) => { + match function.body.as_ref()?.statements.as_slice() { + [Statement::ReturnStatement(ret)] => ret.argument.as_ref()?, + _ => return None, + } + } + _ => return None, + }; + collect_refs(returned, file, out, depth + 1)?; + } + // `A.forRoot()`, `ns.A.forRoot()` + Expression::StaticMemberExpression(member) => { + let method = member.property.name.as_str(); + match member.object.get_inner_expression() { + Expression::Identifier(class) => out.push(raw(None, &class.name, Some(method))), + Expression::StaticMemberExpression(class) => { + let Expression::Identifier(ns) = class.object.get_inner_expression() else { + return None; + }; + out.push(raw(Some(&ns.name), &class.property.name, Some(method))); + } + _ => return None, + } + } + _ => return None, + }, + Expression::ObjectExpression(object) => { + let module = object.properties.iter().find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(p) + if p.key.static_name().is_some_and(|key| key == "ngModule") => + { + Some(&p.value) + } + _ => None, + })?; + collect_refs(module, file, out, depth + 1)?; + } + _ => return None, + } + Some(()) +} diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index b9c5acc8f..6f5d36000 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -1071,6 +1071,45 @@ impl<'a> ComponentMetadata<'a> { } } +// NgModule scopes read across files for partial compilation (#655), by +// `CrossFileAnalyzer::ng_module_component_scopes` and +// `standalone_import_scopes`. + +/// A directive or pipe in a scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScopeKind { + /// A component or directive, with its selector when it's a string. + Directive { selector: Option, is_component: bool }, + /// A pipe, with its name when it's a string. + Pipe { name: Option }, +} + +/// How the compiled file names a class. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportedName { + /// A class the file declares, by its name. + Local(String), + /// The export `name` of the module `specifier`, which the file imports. + Module { specifier: String, name: String }, +} + +/// A directive or pipe of a scope, named for the compiled file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopeEntry { + pub kind: ScopeKind, + pub name: ImportedName, + /// Declared later in the file of the component it's listed for, so read + /// through `forwardRef`. + pub is_forward_reference: bool, +} + +/// The compilation scope of a component an NgModule declares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentScope { + pub component: ImportedName, + pub entries: std::vec::Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index cab8f8aa8..522ff09e2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -32,7 +32,9 @@ use super::decorator::{ use super::definition::{const_value_to_expression, generate_component_definitions}; use super::hoist::{collect_hoist_edits, program_has_angular_decorated_class}; use super::import_elision::{ImportElisionAnalyzer, import_elision_edits}; -use super::metadata::{AngularVersion, ComponentMetadata, HostMetadata, ImportRef}; +use super::metadata::{ + AngularVersion, ComponentMetadata, HostMetadata, ImportRef, TemplateDependency, +}; use super::namespace_registry::NamespaceRegistry; use super::signal_debug_name; use crate::ast::expression::{BindingType, ParsedEventType}; @@ -953,6 +955,7 @@ fn build_set_class_metadata_decls<'a>( crate::CompilationMode::Partial => crate::partial::compile_declare_class_metadata( allocator, core_namespace, + allocator.alloc_str(&crate::partial::declaration_version(options.angular_version)), &class_metadata, ), }; @@ -3307,6 +3310,10 @@ pub fn transform_angular_file( // Collect class positions from original AST for edit-based output. // (class span, class_body_end) let mut class_positions: Vec<(Span, u32)> = Vec::new(); + // Statements that run once the whole file has, when every class it + // declares exists. + #[cfg_attr(not(feature = "cross_file_elision"), allow(unused_mut))] + let mut decls_at_end = String::new(); // The compiled classes of a `namespace` body: ngtsc adds no statement // after them there. let mut namespace_compiled: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); @@ -3566,6 +3573,31 @@ pub fn transform_angular_file( Some(ImportedClass { class_use, is_imported, declaration }) } }); + // The directives and pipes an NgModule in a component's `imports` + // exports, for its partial declaration's `dependencies` (#655). + #[cfg(feature = "cross_file_elision")] + let ng_module_exports = cross_file_analyzer + .as_ref() + .filter(|_| { + options.resolve_imported_values + && matches!(options.compilation_mode, crate::CompilationMode::Partial) + }) + .map(|analyzer| { + move |component: &str, imports: &[ImportRef<'_>]| { + let imports: std::vec::Vec<_> = imports + .iter() + .map(|import| { + (import.namespace.as_ref().map(Ident::as_str), import.name.as_str()) + }) + .collect(); + analyzer.standalone_import_scopes( + std::path::Path::new(path), + source, + component, + &imports, + ) + } + }); // Without cross-file analysis, only the classes declared in this file // have a selector to read: they need no other file, and are never // lazy-loaded. @@ -3714,6 +3746,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), &injectable_metadata, options.compilation_mode, + options.angular_version, ) { jit_emitted_i0_statics = true; let emitter = JsEmitter::new(); @@ -3919,6 +3952,11 @@ pub fn transform_angular_file( let (import_selector, can_defer) = (&local_selector as ImportSelector<'_>, &can_defer as CanDefer<'_>); let (import_selector, can_defer) = (Some(import_selector), Some(can_defer)); + #[cfg(feature = "cross_file_elision")] + let ng_module_exports = + ng_module_exports.as_ref().map(|f| f as NgModuleExports<'_>); + #[cfg(not(feature = "cross_file_elision"))] + let ng_module_exports: Option> = None; // A `namespace` member has nowhere to put its HMR code. let options = match &hmr_options { @@ -3950,6 +3988,7 @@ pub fn transform_angular_file( resolved_resources, import_selector, can_defer, + ng_module_exports, false, ) { Ok(mut compilation_result) => { @@ -4059,6 +4098,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), injectable_metadata, options.compilation_mode, + options.angular_version, ) { let emitter = JsEmitter::new(); property_assignments.push_str(&format!( @@ -4191,6 +4231,11 @@ pub fn transform_angular_file( crate::partial::compile_component_declare_class_metadata( &allocator, core_namespace, + allocator.alloc_str( + &crate::partial::declaration_version( + options.angular_version, + ), + ), &class_metadata, deferred_deps.as_slice(), ) @@ -4409,6 +4454,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), injectable_metadata, options.compilation_mode, + options.angular_version, ) { property_assignments.push_str(&format!( "\nstatic ɵprov = {};", @@ -4508,6 +4554,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), &pipe_metadata, options.compilation_mode, + options.angular_version, ) { // Use JsEmitter to emit both expressions let emitter = JsEmitter::new(); @@ -4540,6 +4587,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), injectable_metadata, options.compilation_mode, + options.angular_version, ) { property_assignments.push_str(&format!( "\nstatic ɵprov = {};", @@ -4623,6 +4671,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), &ng_module_metadata, options.compilation_mode, + options.angular_version, ) { let emitter = JsEmitter::new(); let class_name = ng_module_metadata.class_name.to_string(); @@ -4657,6 +4706,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), injectable_metadata, options.compilation_mode, + options.angular_version, ) { property_assignments.push_str(&format!( "\nstatic ɵprov = {};", @@ -4708,6 +4758,32 @@ pub fn transform_angular_file( external_decls.insert_str(0, &metadata); } + // A partial declaration of a component this module + // declares has no `dependencies`, which ngtsc fills + // with the module's scope from the whole program + // (#655). This file names the components, so it sets + // their scope at runtime, when cross-file analysis + // reads all of it. + #[cfg(feature = "cross_file_elision")] + if matches!(options.compilation_mode, crate::CompilationMode::Partial) + && options.resolve_imported_values + && let Some(analyzer) = &cross_file_analyzer + && let Some(scopes) = analyzer.ng_module_component_scopes( + std::path::Path::new(path), + source, + &class_name, + ) + { + for scope in &scopes { + decls_at_end.push('\n'); + decls_at_end.push_str(&set_component_scope_statement( + allocator, + scope, + &mut file_namespace_registry, + )); + } + } + // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push((class.span, class.body.span.end)); class_definitions.insert( @@ -4773,6 +4849,7 @@ pub fn transform_angular_file( &service_metadata, type_argument_count, options.compilation_mode, + options.angular_version, ); let emitter = JsEmitter::new(); @@ -4852,6 +4929,7 @@ pub fn transform_angular_file( file_namespace_registry.angular_core_ns().as_str(), &injectable_metadata, options.compilation_mode, + options.angular_version, ) { let emitter = JsEmitter::new(); let class_name = injectable_metadata.class_name.to_string(); @@ -5091,6 +5169,9 @@ pub fn transform_angular_file( } } } + if !decls_at_end.is_empty() { + edits.push(Edit::insert(parser_ret.program.span.end, decls_at_end)); + } if !decls_after_imports.is_empty() { // ngtsc moves the imports above the rest of the file. Imports load // before the module body runs anyway, so a statement ahead of the @@ -5294,6 +5375,85 @@ struct HmrUpdateParts { styles: Vec, } +/// The `dependencies` entries of the directives and pipes of `entries`, +/// with the modules they come from imported as namespaces. `None` when a +/// pipe's name isn't known: the linker requires it. +fn scope_dependencies<'a>( + allocator: &'a Allocator, + entries: &[super::metadata::ScopeEntry], + registry: &mut NamespaceRegistry<'a>, +) -> Option>> { + use super::metadata::{ImportedName, ScopeKind}; + let mut dependencies = OxcVec::with_capacity_in(entries.len(), &allocator); + for entry in entries { + let ident = |s: &str| Ident::from(allocator.alloc_str(s)); + let (namespace, name) = match &entry.name { + ImportedName::Local(name) => (None, ident(name)), + ImportedName::Module { specifier, name } => { + (Some(registry.get_or_assign(&ident(specifier))), ident(name)) + } + }; + let dependency = match &entry.kind { + // The linker only requires a selector to be a string. + ScopeKind::Directive { selector, is_component } => TemplateDependency::directive( + allocator, + name, + ident(selector.as_deref().unwrap_or("*")), + *is_component, + ), + ScopeKind::Pipe { name: pipe_name } => { + TemplateDependency::pipe(allocator, name, ident(pipe_name.as_deref()?)) + } + }; + let dependency = match namespace { + Some(namespace) => dependency.with_type_namespace(namespace), + None => dependency, + }; + dependencies.push(if entry.is_forward_reference { + dependency.with_forward_reference() + } else { + dependency + }); + } + Some(dependencies) +} + +/// `i0.ɵɵsetComponentScope(Cmp, () => [directives], () => [pipes]);` for +/// `scope`, with the modules its classes come from imported as namespaces. +/// The lists are read when the component first renders: a module in an +/// import cycle with this file may not have run yet. +#[cfg(feature = "cross_file_elision")] +fn set_component_scope_statement<'a>( + allocator: &'a Allocator, + scope: &super::metadata::ComponentScope, + registry: &mut NamespaceRegistry<'a>, +) -> String { + use super::metadata::{ImportedName, ScopeKind}; + let mut name_of = |name: &ImportedName| match name { + ImportedName::Local(name) => name.clone(), + ImportedName::Module { specifier, name } => { + let namespace = registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))); + format!("{namespace}.{name}") + } + }; + let component = name_of(&scope.component); + let mut directives = Vec::new(); + let mut pipes = Vec::new(); + for entry in &scope.entries { + let list = match entry.kind { + ScopeKind::Directive { .. } => &mut directives, + ScopeKind::Pipe { .. } => &mut pipes, + }; + list.push(name_of(&entry.name)); + } + format!( + "{}.ɵɵsetComponentScope({component}, () => [{}], () => [{}]);", + registry.angular_core_ns(), + directives.join(", "), + pipes.join(", ") + ) +} + /// 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. @@ -5611,6 +5771,7 @@ pub fn compile_component_hmr_update( Some(resources), None, None, + None, true, ) { Ok(compiled) => compiled, @@ -5674,6 +5835,8 @@ fn compile_component_partial<'a>( view_queries: &[R3QueryMetadata<'a>], content_queries: &[R3QueryMetadata<'a>], defer_block_dependencies: &[Option>], + import_scopes: &[(Ident<'a>, OxcVec<'a, TemplateDependency<'a>>)], + angular_version: Option, pool_starting_index: u32, namespace_registry: &mut NamespaceRegistry<'a>, ) -> FullCompilationResult<'a> { @@ -5683,16 +5846,20 @@ fn compile_component_partial<'a>( content_queries, view_queries, defer_block_dependencies, + import_scopes, }; + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let cmp_expr = crate::partial::compile_declare_component_from_metadata( allocator, core_namespace, + version, metadata, &inputs, ); let fac_expr = crate::partial::component::compile_declare_factory_for_component( allocator, core_namespace, + version, metadata, namespace_registry, ); @@ -5815,6 +5982,14 @@ fn html_parse_error_diagnostic( /// refers to (see [`ImportedClass`]). type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; +/// For each `imports` entry, the directives and pipes the NgModule it refers +/// to adds, when cross-file analysis reads them. +type NgModuleExports<'f> = + &'f dyn Fn( + &str, + &[ImportRef<'_>], + ) -> std::vec::Vec>>; + /// What `@defer` reads of the standalone component or directive an `imports` /// entry refers to. struct ImportedClass { @@ -6147,6 +6322,7 @@ fn compile_component_full<'a>( resolved_resources: Option<&ResolvedResources>, import_selector: Option>, can_defer: Option>, + ng_module_exports: Option>, hmr_update: bool, ) -> Result, Vec> { use oxc_allocator::FromIn; @@ -6268,6 +6444,17 @@ fn compile_component_full<'a>( ) }) .collect(); + let import_scopes: std::vec::Vec<_> = ng_module_exports + .map(|exports| exports(metadata.class_name.as_str(), &metadata.import_refs)) + .unwrap_or_default() + .into_iter() + .zip(&metadata.import_refs) + .filter_map(|(entries, import)| { + let entries = entries?; + let dependencies = scope_dependencies(allocator, &entries, namespace_registry)?; + Some((import.key, dependencies)) + }) + .collect(); let mut result = compile_component_partial( allocator, namespace_registry.angular_core_ns().as_str(), @@ -6276,6 +6463,8 @@ fn compile_component_full<'a>( &view_queries, &content_queries, &defer_block_dependencies, + &import_scopes, + options.angular_version, pool_starting_index, namespace_registry, ); diff --git a/crates/oxc_angular_compiler/src/directive/definition.rs b/crates/oxc_angular_compiler/src/directive/definition.rs index ff682b244..a7a458338 100644 --- a/crates/oxc_angular_compiler/src/directive/definition.rs +++ b/crates/oxc_angular_compiler/src/directive/definition.rs @@ -109,10 +109,16 @@ pub fn generate_directive_definitions<'a>( // constants are emitted here. `angular_version` is irrelevant // because partial mode emits the verbatim selector and lets // the linker pick up the consumer's runtime version. + let version = + allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let fac_definition = - compile_declare_factory_for_directive(allocator, core_namespace, metadata); - let dir_definition = - compile_declare_directive_from_metadata(allocator, core_namespace, metadata); + compile_declare_factory_for_directive(allocator, core_namespace, version, metadata); + let dir_definition = compile_declare_directive_from_metadata( + allocator, + core_namespace, + version, + metadata, + ); DirectiveDefinitions { dir_definition, fac_definition, diff --git a/crates/oxc_angular_compiler/src/injectable/definition.rs b/crates/oxc_angular_compiler/src/injectable/definition.rs index 0c32d3dde..bea554ed6 100644 --- a/crates/oxc_angular_compiler/src/injectable/definition.rs +++ b/crates/oxc_angular_compiler/src/injectable/definition.rs @@ -99,7 +99,9 @@ pub fn generate_injectable_definition<'a>( core_namespace: &'a str, metadata: &R3InjectableMetadata<'a>, compilation_mode: CompilationMode, + angular_version: Option, ) -> InjectableDefinition<'a> { + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); // IMPORTANT: Generate ɵfac BEFORE ɵprov to match Angular's namespace index assignment order. // Angular processes results in order [fac, prov, ...] during the transform phase // (see packages/compiler-cli/src/ngtsc/annotations/src/injectable.ts:218-253), @@ -112,10 +114,18 @@ pub fn generate_injectable_definition<'a>( InjectableDefinition { prov_definition: prov_result.expression, fac_definition } } CompilationMode::Partial => { - let fac_definition = - compile_declare_factory_for_injectable(allocator, core_namespace, metadata); - let prov_definition = - compile_declare_injectable_from_metadata(allocator, core_namespace, metadata); + let fac_definition = compile_declare_factory_for_injectable( + allocator, + core_namespace, + version, + metadata, + ); + let prov_definition = compile_declare_injectable_from_metadata( + allocator, + core_namespace, + version, + metadata, + ); InjectableDefinition { prov_definition, fac_definition } } } @@ -213,9 +223,16 @@ pub fn generate_injectable_definition_from_decorator<'a>( core_namespace: &'a str, metadata: &InjectableMetadata<'a>, compilation_mode: CompilationMode, + angular_version: Option, ) -> Option> { let r3_metadata = metadata.to_r3_metadata(allocator)?; - Some(generate_injectable_definition(allocator, core_namespace, &r3_metadata, compilation_mode)) + Some(generate_injectable_definition( + allocator, + core_namespace, + &r3_metadata, + compilation_mode, + angular_version, + )) } #[cfg(test)] @@ -242,8 +259,13 @@ mod tests { .build() .unwrap(); - let definition = - generate_injectable_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_injectable_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&definition.prov_definition); @@ -274,8 +296,13 @@ mod tests { .build() .unwrap(); - let definition = - generate_injectable_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_injectable_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&definition.prov_definition); @@ -301,8 +328,13 @@ mod tests { .build() .unwrap(); - let definition = - generate_injectable_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_injectable_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&definition.prov_definition); @@ -325,8 +357,13 @@ mod tests { .build() .unwrap(); - let definition = - generate_injectable_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_injectable_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); let emitter = JsEmitter::new(); let js = emitter.emit_expression(&definition.prov_definition); @@ -352,8 +389,13 @@ mod tests { .build() .unwrap(); - let definition = - generate_injectable_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_injectable_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); // The prov_definition should be an InvokeFunction with pure=true match &definition.prov_definition { @@ -407,6 +449,7 @@ mod tests { "i0", &metadata, CompilationMode::Full, + None, ); let definition = definition.expect("Should generate definition"); @@ -459,6 +502,7 @@ mod tests { "i0", &metadata, CompilationMode::Full, + None, ) .expect("Should generate definition"); JsEmitter::new().emit_expression(&definition.fac_definition) @@ -529,6 +573,7 @@ mod tests { "i0", &metadata, CompilationMode::Full, + None, ); let definition = definition.expect("Should generate definition"); diff --git a/crates/oxc_angular_compiler/src/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index a94afdf3f..175f0e7b1 100644 --- a/crates/oxc_angular_compiler/src/ng_module/definition.rs +++ b/crates/oxc_angular_compiler/src/ng_module/definition.rs @@ -195,7 +195,9 @@ pub fn generate_full_ng_module_definition<'a>( core_namespace: &'a str, metadata: &NgModuleMetadata<'a>, compilation_mode: CompilationMode, + angular_version: Option, ) -> Option> { + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let r3_metadata = metadata.to_r3_metadata(allocator)?; // IMPORTANT: Generate ɵfac BEFORE ɵmod and ɵinj to match Angular's namespace index assignment order. @@ -224,17 +226,26 @@ pub fn generate_full_ng_module_definition<'a>( let fac_definition = compile_declare_factory_for_ng_module( allocator, core_namespace, + version, &r3_metadata, metadata.deps.as_ref(), ); - let mod_definition = - compile_declare_ng_module_from_metadata(allocator, core_namespace, &r3_metadata); + let mod_definition = compile_declare_ng_module_from_metadata( + allocator, + core_namespace, + version, + &r3_metadata, + ); // Build the injector metadata using the same conversion the // full path uses (`generate_ng_module_inj`'s builder), then // hand it to the partial injector emitter. let inj_metadata = build_injector_metadata(allocator, metadata); - let inj_definition = - compile_declare_injector_from_metadata(allocator, core_namespace, &inj_metadata); + let inj_definition = compile_declare_injector_from_metadata( + allocator, + core_namespace, + version, + &inj_metadata, + ); Some(FullNgModuleDefinition { mod_definition, fac_definition, @@ -653,8 +664,13 @@ mod tests { assert!(deps[0].skip_self, "Should have skip_self"); // Generate the full definition and check the output - let definition = - generate_full_ng_module_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let definition = generate_full_ng_module_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ); let definition = definition.expect("Should generate definition"); let js = emit_full_ng_module_definition("CoreModule", &definition); @@ -721,9 +737,14 @@ mod tests { assert_eq!(deps.len(), 1); assert!(deps[0].token.is_some(), "@Inject(TOKEN) should give the dep a token"); - let definition = - generate_full_ng_module_definition(&allocator, "i0", &metadata, CompilationMode::Full) - .expect("Should generate definition"); + let definition = generate_full_ng_module_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ) + .expect("Should generate definition"); let js = emit_full_ng_module_definition("M", &definition); assert!( !js.contains("invalidFactoryDep"), @@ -751,9 +772,14 @@ mod tests { "#; let metadata = module_metadata_from_code(&allocator, code); - let definition = - generate_full_ng_module_definition(&allocator, "i0", &metadata, CompilationMode::Full) - .expect("Should generate definition"); + let definition = generate_full_ng_module_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ) + .expect("Should generate definition"); let js = emit_full_ng_module_definition("M", &definition); assert!(js.contains("ɵɵinject(Other"), "Should inject Other, got: {js}"); assert!(!js.contains("ɵɵinject(Real"), "Should not inject Real, got: {js}"); @@ -777,6 +803,7 @@ mod tests { "i0", &metadata, CompilationMode::Partial, + None, ) .expect("Should generate definition"); let js = emit_full_ng_module_definition("M", &definition); @@ -796,9 +823,14 @@ mod tests { let code = allocator.alloc_str(code); let metadata = module_metadata_from_code(&allocator, code); let class_name = metadata.class_name.to_string(); - let definition = - generate_full_ng_module_definition(&allocator, "i0", &metadata, CompilationMode::Full) - .expect("Should generate definition"); + let definition = generate_full_ng_module_definition( + &allocator, + "i0", + &metadata, + CompilationMode::Full, + None, + ) + .expect("Should generate definition"); emit_full_ng_module_definition(&class_name, &definition) } diff --git a/crates/oxc_angular_compiler/src/partial/class_metadata.rs b/crates/oxc_angular_compiler/src/partial/class_metadata.rs index 2ba5e0f85..7856a41ba 100644 --- a/crates/oxc_angular_compiler/src/partial/class_metadata.rs +++ b/crates/oxc_angular_compiler/src/partial/class_metadata.rs @@ -32,7 +32,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; -use super::{MIN_VERSION_CLASS_METADATA, MIN_VERSION_CLASS_METADATA_ASYNC, PLACEHOLDER_VERSION}; +use super::{MIN_VERSION_CLASS_METADATA, MIN_VERSION_CLASS_METADATA_ASYNC}; use crate::class_metadata::{ R3ClassMetadata, R3DeferPerComponentDependency, compile_component_metadata_async_resolver, }; @@ -46,12 +46,13 @@ use crate::r3::Identifiers; pub fn compile_declare_class_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3ClassMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_CLASS_METADATA)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -134,6 +135,7 @@ pub fn compile_declare_class_metadata<'a>( pub fn compile_declare_class_metadata_async<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3ClassMetadata<'a>, dependencies: &[R3DeferPerComponentDependency<'a>], ) -> OutputExpression<'a> { @@ -187,7 +189,7 @@ pub fn compile_declare_class_metadata_async<'a>( let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_CLASS_METADATA_ASYNC)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -233,13 +235,14 @@ pub fn compile_declare_class_metadata_async<'a>( pub fn compile_component_declare_class_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3ClassMetadata<'a>, dependencies: &[R3DeferPerComponentDependency<'a>], ) -> OutputExpression<'a> { if dependencies.is_empty() { - compile_declare_class_metadata(allocator, core_namespace, meta) + compile_declare_class_metadata(allocator, core_namespace, version, meta) } else { - compile_declare_class_metadata_async(allocator, core_namespace, meta, dependencies) + compile_declare_class_metadata_async(allocator, core_namespace, version, meta, dependencies) } } @@ -268,7 +271,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -278,7 +281,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index afb988a6e..9de7f1232 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -35,7 +35,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; use super::factory::compile_declare_factory_function; -use super::{PLACEHOLDER_VERSION, wrap_forward_ref}; +use super::wrap_forward_ref; use crate::component::R3DependencyMetadata as ComponentDep; use crate::component::{ ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, HostDirectiveMetadata, @@ -74,12 +74,17 @@ pub struct PartialComponentInputs<'a, 'm> { /// dependencies, in the order Angular's binder lists the blocks. Empty /// when the blocks weren't resolved. pub defer_block_dependencies: &'m [Option>], + /// For an `imports` entry (by [`ImportRef::key`]) that is an NgModule, + /// the directives and pipes it exports, when cross-file analysis read + /// them (#655). + pub import_scopes: &'m [(Ident<'a>, Vec<'a, TemplateDependency<'a>>)], } /// Emits the `ɵɵngDeclareComponent` call for a component's `ɵcmp` static. pub fn compile_declare_component_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &ComponentMetadata<'a>, inputs: &PartialComponentInputs<'a, '_>, ) -> OutputExpression<'a> { @@ -87,7 +92,7 @@ pub fn compile_declare_component_from_metadata<'a>( let min_version = compute_min_version(meta, inputs); entries.push(string_entry(allocator, "minVersion", min_version)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); let type_expr = OutputExpression::ReadVar(Box::new_in( ReadVarExpr { name: meta.class_name.clone(), source_span: None }, @@ -97,10 +102,11 @@ pub fn compile_declare_component_from_metadata<'a>( // isStandalone — emit only when not standalone. Matches the // Pipe/Directive convention; linker defaults to true. - if !meta.standalone { + // An older linker defaults to `false`, so it's told `true`. + if !meta.standalone || !super::standalone_is_default(version) { entries.push(LiteralMapEntry::new( Ident::from("isStandalone"), - bool_lit(allocator, false), + bool_lit(allocator, meta.standalone), false, )); } @@ -268,9 +274,12 @@ pub fn compile_declare_component_from_metadata<'a>( // in Direct mode. The linker reads only the `type` expression from // each entry (see `linker/mod.rs:1844`), so the placeholder kind/ // selector is harmless — the emitted full ɵcmp ends up with - // `dependencies: [Import1, Import2, …]` regardless. + // `dependencies: [Import1, Import2, …]` regardless. An NgModule whose + // exports were read is an `ngmodule` entry followed by those, like + // ngtsc's: the runtime can't read them from the module, which the + // Angular CLI's linker emits without its scope. let synthetic_declarations = if meta.declarations.is_empty() && !meta.import_refs.is_empty() { - Some(lower_imports_to_declarations(allocator, &meta.import_refs)) + Some(lower_imports_to_declarations(allocator, &meta.import_refs, inputs.import_scopes)) } else { None }; @@ -383,6 +392,7 @@ pub fn compile_declare_component_from_metadata<'a>( pub fn compile_declare_factory_for_component<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &ComponentMetadata<'a>, namespace_registry: &mut NamespaceRegistry<'a>, ) -> OutputExpression<'a> { @@ -404,7 +414,7 @@ pub fn compile_declare_factory_for_component<'a>( ), target: FactoryTarget::Component, }); - compile_declare_factory_function(allocator, core_namespace, &factory_meta) + compile_declare_factory_function(allocator, core_namespace, version, &factory_meta) } // ---- min version --------------------------------------------------------- @@ -728,18 +738,27 @@ fn host_directives_mapping_array<'a>( fn lower_imports_to_declarations<'a>( allocator: &'a Allocator, imports: &[ImportRef<'a>], + import_scopes: &[(Ident<'a>, Vec<'a, TemplateDependency<'a>>)], ) -> Vec<'a, TemplateDependency<'a>> { let mut out: Vec<'a, TemplateDependency<'a>> = Vec::with_capacity_in(imports.len(), &allocator); for (index, import) in imports.iter().enumerate() { if imports[..index].iter().any(|seen| seen.key == import.key) { continue; } - let mut dependency = TemplateDependency::directive( - &allocator, - import.name, - Ident::from("*"), - false, // is_component — unknown at this point - ); + let scope = import_scopes.iter().find(|(key, _)| *key == import.key); + let mut dependency = match scope { + Some(_) => TemplateDependency { + kind: TemplateDependencyKind::NgModule, + selector: None, + ..TemplateDependency::directive(allocator, import.name, Ident::from("*"), false) + }, + None => TemplateDependency::directive( + allocator, + import.name, + Ident::from("*"), + false, // is_component — unknown at this point + ), + }; if let Some(namespace) = import.namespace { dependency = dependency.with_type_namespace(namespace); } @@ -747,6 +766,20 @@ fn lower_imports_to_declarations<'a>( dependency = dependency.with_forward_reference(); } out.push(dependency); + for exported in scope.iter().flat_map(|(_, exported)| exported) { + let is_listed = out.iter().any(|listed| { + listed.type_namespace == exported.type_namespace + && listed.type_name == exported.type_name + }); + if !is_listed { + out.push(TemplateDependency { + inputs: Vec::new_in(&allocator), + outputs: Vec::new_in(&allocator), + export_as: Vec::new_in(&allocator), + ..*exported + }); + } + } } out } @@ -1022,9 +1055,9 @@ fn string_literal_str<'a>(allocator: &'a Allocator, value: &str) -> OutputExpres fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { - LiteralMapEntry::new(Ident::from(key), string_literal_static(allocator, value), false) + LiteralMapEntry::new(Ident::from(key), string_literal_str(allocator, value), false) } fn bool_lit<'a>(allocator: &'a Allocator, value: bool) -> OutputExpression<'a> { diff --git a/crates/oxc_angular_compiler/src/partial/directive.rs b/crates/oxc_angular_compiler/src/partial/directive.rs index 39ad7839e..14c701f69 100644 --- a/crates/oxc_angular_compiler/src/partial/directive.rs +++ b/crates/oxc_angular_compiler/src/partial/directive.rs @@ -40,7 +40,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; use super::factory::compile_declare_factory_function; -use super::{PLACEHOLDER_VERSION, wrap_forward_ref}; +use super::wrap_forward_ref; use crate::directive::{ QueryPredicate, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, @@ -59,9 +59,10 @@ use crate::r3::Identifiers; pub fn compile_declare_directive_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3DirectiveMetadata<'a>, ) -> OutputExpression<'a> { - let entries = create_directive_definition_map(allocator, core_namespace, meta); + let entries = create_directive_definition_map(allocator, core_namespace, version, meta); invoke_declare(allocator, core_namespace, Identifiers::DECLARE_DIRECTIVE, entries) } @@ -74,21 +75,23 @@ pub fn compile_declare_directive_from_metadata<'a>( pub(crate) fn create_directive_definition_map<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3DirectiveMetadata<'a>, ) -> Vec<'a, LiteralMapEntry<'a>> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); let min_version = compute_min_version(meta); entries.push(string_entry(allocator, "minVersion", min_version)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new(Ident::from("type"), meta.r#type.clone_in(allocator), false)); // isStandalone: emit only when not standalone — matches Pipe/Component // convention. Linker defaults to true (v19+ runtime default). - if !meta.is_standalone { + // An older linker defaults to `false`, so it's told `true`. + if !meta.is_standalone || !super::standalone_is_default(version) { entries.push(LiteralMapEntry::new( Ident::from("isStandalone"), - bool_lit(allocator, false), + bool_lit(allocator, meta.is_standalone), false, )); } @@ -206,6 +209,7 @@ pub(crate) fn create_directive_definition_map<'a>( pub fn compile_declare_factory_for_directive<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3DirectiveMetadata<'a>, ) -> OutputExpression<'a> { let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { @@ -216,7 +220,7 @@ pub fn compile_declare_factory_for_directive<'a>( deps: clone_factory_deps(allocator, &meta.deps, meta.uses_inheritance), target: FactoryTarget::Directive, }); - compile_declare_factory_function(allocator, core_namespace, &factory_meta) + compile_declare_factory_function(allocator, core_namespace, version, &factory_meta) } /// Public min-version calculator. Exposed so the component emitter (next @@ -697,7 +701,7 @@ fn string_literal_owned<'a>(allocator: &'a Allocator, value: Ident<'a>) -> Outpu fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new( Ident::from(key), diff --git a/crates/oxc_angular_compiler/src/partial/factory.rs b/crates/oxc_angular_compiler/src/partial/factory.rs index 601b664bf..e768d43ad 100644 --- a/crates/oxc_angular_compiler/src/partial/factory.rs +++ b/crates/oxc_angular_compiler/src/partial/factory.rs @@ -12,7 +12,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; -use super::{MIN_VERSION_FACTORY, PLACEHOLDER_VERSION}; +use super::MIN_VERSION_FACTORY; use crate::factory::{FactoryTarget, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata}; use crate::output::ast::{ InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, @@ -31,6 +31,7 @@ use crate::r3::Identifiers; pub fn compile_declare_factory_function<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3FactoryMetadata<'a>, ) -> OutputExpression<'a> { let base = meta.base(); @@ -38,7 +39,7 @@ pub fn compile_declare_factory_function<'a>( let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_FACTORY)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -231,7 +232,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -241,7 +242,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/injectable.rs b/crates/oxc_angular_compiler/src/partial/injectable.rs index 5c9855ebd..df0d95552 100644 --- a/crates/oxc_angular_compiler/src/partial/injectable.rs +++ b/crates/oxc_angular_compiler/src/partial/injectable.rs @@ -28,7 +28,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; use super::factory::compile_declare_factory_function; -use super::{MIN_VERSION_INJECTABLE, PLACEHOLDER_VERSION, wrap_forward_ref}; +use super::{MIN_VERSION_INJECTABLE, wrap_forward_ref}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, @@ -47,12 +47,13 @@ use crate::r3::Identifiers; pub fn compile_declare_injectable_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3InjectableMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_INJECTABLE)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -153,6 +154,7 @@ pub fn compile_declare_injectable_from_metadata<'a>( pub fn compile_declare_factory_for_injectable<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3InjectableMetadata<'a>, ) -> OutputExpression<'a> { let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { @@ -164,7 +166,7 @@ pub fn compile_declare_factory_for_injectable<'a>( target: FactoryTarget::Injectable, }); - compile_declare_factory_function(allocator, core_namespace, &factory_meta) + compile_declare_factory_function(allocator, core_namespace, version, &factory_meta) } // ---- helpers --------------------------------------------------------------- @@ -308,7 +310,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -318,7 +320,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/injector.rs b/crates/oxc_angular_compiler/src/partial/injector.rs index 858ae1707..eef49f3ad 100644 --- a/crates/oxc_angular_compiler/src/partial/injector.rs +++ b/crates/oxc_angular_compiler/src/partial/injector.rs @@ -18,7 +18,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; -use super::{MIN_VERSION_INJECTOR, PLACEHOLDER_VERSION}; +use super::MIN_VERSION_INJECTOR; use crate::injector::R3InjectorMetadata; use crate::output::ast::{ InvokeFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, @@ -30,12 +30,13 @@ use crate::r3::Identifiers; pub fn compile_declare_injector_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3InjectorMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_INJECTOR)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -131,7 +132,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -141,7 +142,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/mod.rs b/crates/oxc_angular_compiler/src/partial/mod.rs index 16b43e328..b4c62bc1e 100644 --- a/crates/oxc_angular_compiler/src/partial/mod.rs +++ b/crates/oxc_angular_compiler/src/partial/mod.rs @@ -125,14 +125,42 @@ pub(crate) fn wrap_forward_ref<'a>( )) } -/// The literal text used for the `version` field of every partial -/// declaration. Upstream substitutes this at npm publish; we keep the same -/// sentinel so the existing linker version-check logic recognizes it. +/// The `version` field ngtsc's own development builds write. /// -/// Matches upstream `packages/compiler/src/render3/partial/*.ts` — every -/// emitter writes `"0.0.0-PLACEHOLDER"` directly. +/// npm publishing replaces it with the release version, and a published +/// linker reads it as `0.0.0`, which turns off the template syntax and +/// defaults added since (block syntax, standalone, `OnPush`), so oxc writes a +/// real version instead (see [`declaration_version`]). pub const PLACEHOLDER_VERSION: &str = "0.0.0-PLACEHOLDER"; +/// The newest Angular release oxc's output follows: what a compilation +/// without a known `angular_version` targets. +pub const LATEST_VERSION: &str = "22.2.3"; + +/// The `version` field of a partial declaration, which the linker reads its +/// defaults by: the Angular version compiled for, as ngtsc writes its own +/// version, else [`LATEST_VERSION`]. +pub fn declaration_version(angular_version: Option) -> String { + match angular_version { + // `AngularVersion::parse` reads the placeholder as the newest version. + Some(version) if version.major != u32::MAX => { + format!("{}.{}.{}", version.major, version.minor, version.patch) + } + _ => LATEST_VERSION.to_string(), + } +} + +/// Whether the linker reads a declaration of `version` without +/// `isStandalone` as standalone: from v19 on, as Angular's linker's +/// `getDefaultStandaloneValue` does. +pub(crate) fn standalone_is_default(version: &str) -> bool { + version + .split('.') + .next() + .and_then(|major| major.parse::().ok()) + .is_some_and(|major| major >= 19) +} + /// Minimum linker version that understands a `ɵɵngDeclareFactory` shape. /// /// Matches upstream `packages/compiler/src/render3/partial/factory.ts:25`. diff --git a/crates/oxc_angular_compiler/src/partial/ng_module.rs b/crates/oxc_angular_compiler/src/partial/ng_module.rs index 632b94918..73c7a48ad 100644 --- a/crates/oxc_angular_compiler/src/partial/ng_module.rs +++ b/crates/oxc_angular_compiler/src/partial/ng_module.rs @@ -29,8 +29,8 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; +use super::MIN_VERSION_NG_MODULE; use super::factory::compile_declare_factory_function; -use super::{MIN_VERSION_NG_MODULE, PLACEHOLDER_VERSION}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3FactoryDeps, R3FactoryMetadata, }; @@ -45,12 +45,13 @@ use crate::r3::Identifiers; pub fn compile_declare_ng_module_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3NgModuleMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_NG_MODULE)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -93,6 +94,7 @@ pub fn compile_declare_ng_module_from_metadata<'a>( pub fn compile_declare_factory_for_ng_module<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3NgModuleMetadata<'a>, deps: Option<&Vec<'a, crate::factory::R3DependencyMetadata<'a>>>, ) -> OutputExpression<'a> { @@ -126,7 +128,7 @@ pub fn compile_declare_factory_for_ng_module<'a>( deps: factory_deps, target: FactoryTarget::NgModule, }); - compile_declare_factory_function(allocator, core_namespace, &factory_meta) + compile_declare_factory_function(allocator, core_namespace, version, &factory_meta) } // ---- ref list helpers ------------------------------------------------------ @@ -231,7 +233,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -241,7 +243,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/pipe.rs b/crates/oxc_angular_compiler/src/partial/pipe.rs index 9511e4794..3a95d2517 100644 --- a/crates/oxc_angular_compiler/src/partial/pipe.rs +++ b/crates/oxc_angular_compiler/src/partial/pipe.rs @@ -22,8 +22,8 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; +use super::MIN_VERSION_PIPE; use super::factory::compile_declare_factory_function; -use super::{MIN_VERSION_PIPE, PLACEHOLDER_VERSION}; use crate::factory::{ FactoryTarget, R3ConstructorFactoryMetadata, R3DependencyMetadata, R3FactoryDeps, R3FactoryMetadata, @@ -41,12 +41,13 @@ use crate::r3::Identifiers; pub fn compile_declare_pipe_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3PipeMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_PIPE)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -58,10 +59,11 @@ pub fn compile_declare_pipe_from_metadata<'a>( // plain bool, so emit only when false — the linker defaults to true, // matching the v19+ runtime. (Skipping `isStandalone: true` produces a // smaller payload that the linker handles identically.) - if !meta.is_standalone { + // An older linker defaults to `false`, so it's told `true`. + if !meta.is_standalone || !super::standalone_is_default(version) { entries.push(LiteralMapEntry::new( Ident::from("isStandalone"), - bool_literal(allocator, false), + bool_literal(allocator, meta.is_standalone), false, )); } @@ -118,6 +120,7 @@ pub fn compile_declare_pipe_from_metadata<'a>( pub fn compile_declare_factory_for_pipe<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3PipeMetadata<'a>, ) -> OutputExpression<'a> { let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { @@ -129,7 +132,7 @@ pub fn compile_declare_factory_for_pipe<'a>( target: FactoryTarget::Pipe, }); - compile_declare_factory_function(allocator, core_namespace, &factory_meta) + compile_declare_factory_function(allocator, core_namespace, version, &factory_meta) } // ---- helpers --------------------------------------------------------------- @@ -189,7 +192,7 @@ fn namespaced_prop<'a>( )) } -fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputExpression<'a> { +fn string_literal<'a>(allocator: &'a Allocator, value: &'a str) -> OutputExpression<'a> { OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(Ident::from(value)), source_span: None }, &allocator, @@ -199,7 +202,7 @@ fn string_literal<'a>(allocator: &'a Allocator, value: &'static str) -> OutputEx fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new(Ident::from(key), string_literal(allocator, value), false) } diff --git a/crates/oxc_angular_compiler/src/partial/service.rs b/crates/oxc_angular_compiler/src/partial/service.rs index ae06f2c05..8f978d736 100644 --- a/crates/oxc_angular_compiler/src/partial/service.rs +++ b/crates/oxc_angular_compiler/src/partial/service.rs @@ -24,7 +24,7 @@ use oxc_allocator::{Allocator, Box, Vec}; use oxc_str::Ident; -use super::{MIN_VERSION_SERVICE, PLACEHOLDER_VERSION}; +use super::MIN_VERSION_SERVICE; use crate::output::ast::{ InvokeFunctionExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr, @@ -39,12 +39,13 @@ use crate::service::R3ServiceMetadata; pub fn compile_declare_service_from_metadata<'a>( allocator: &'a Allocator, core_namespace: &'a str, + version: &'a str, meta: &R3ServiceMetadata<'a>, ) -> OutputExpression<'a> { let mut entries: Vec<'a, LiteralMapEntry<'a>> = Vec::new_in(&allocator); entries.push(string_entry(allocator, "minVersion", MIN_VERSION_SERVICE)); - entries.push(string_entry(allocator, "version", PLACEHOLDER_VERSION)); + entries.push(string_entry(allocator, "version", version)); entries.push(LiteralMapEntry::new( Ident::from("ngImport"), read_var(allocator, core_namespace), @@ -113,7 +114,7 @@ fn read_var<'a>(allocator: &'a Allocator, name: &'a str) -> OutputExpression<'a> fn string_entry<'a>( allocator: &'a Allocator, key: &'static str, - value: &'static str, + value: &'a str, ) -> LiteralMapEntry<'a> { LiteralMapEntry::new( Ident::from(key), diff --git a/crates/oxc_angular_compiler/src/pipe/definition.rs b/crates/oxc_angular_compiler/src/pipe/definition.rs index 9603af84d..480ec1fdb 100644 --- a/crates/oxc_angular_compiler/src/pipe/definition.rs +++ b/crates/oxc_angular_compiler/src/pipe/definition.rs @@ -147,7 +147,9 @@ pub fn generate_full_pipe_definition_from_decorator<'a>( core_namespace: &'a str, metadata: &PipeMetadata<'a>, compilation_mode: CompilationMode, + angular_version: Option, ) -> Option> { + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); let r3_metadata = metadata.to_r3_metadata(allocator)?; // IMPORTANT: Generate ɵfac BEFORE ɵpipe to match Angular's namespace index assignment order. // Angular processes results in order [fac, def, ...] during the transform phase @@ -162,9 +164,13 @@ pub fn generate_full_pipe_definition_from_decorator<'a>( } CompilationMode::Partial => { let fac_definition = - compile_declare_factory_for_pipe(allocator, core_namespace, &r3_metadata); - let pipe_definition = - compile_declare_pipe_from_metadata(allocator, core_namespace, &r3_metadata); + compile_declare_factory_for_pipe(allocator, core_namespace, version, &r3_metadata); + let pipe_definition = compile_declare_pipe_from_metadata( + allocator, + core_namespace, + version, + &r3_metadata, + ); Some(FullPipeDefinition { pipe_definition, fac_definition }) } } diff --git a/crates/oxc_angular_compiler/src/service/definition.rs b/crates/oxc_angular_compiler/src/service/definition.rs index 4780cb39a..0edb18a95 100644 --- a/crates/oxc_angular_compiler/src/service/definition.rs +++ b/crates/oxc_angular_compiler/src/service/definition.rs @@ -38,7 +38,9 @@ pub fn generate_service_definition<'a>( core_namespace: &'a str, metadata: &R3ServiceMetadata<'a>, compilation_mode: CompilationMode, + angular_version: Option, ) -> ServiceDefinition<'a> { + let version = allocator.alloc_str(&crate::partial::declaration_version(angular_version)); // Generate ɵfac BEFORE ɵprov so namespace-index assignment order matches // upstream's [fac, prov, ...] ordering. match compilation_mode { @@ -51,10 +53,11 @@ pub fn generate_service_definition<'a>( let fac_definition = compile_declare_factory_function( allocator, core_namespace, + version, &service_factory_metadata(allocator, metadata), ); let prov_definition = - compile_declare_service_from_metadata(allocator, core_namespace, metadata); + compile_declare_service_from_metadata(allocator, core_namespace, version, metadata); ServiceDefinition { prov_definition, fac_definition } } } @@ -67,9 +70,16 @@ pub fn generate_service_definition_from_decorator<'a>( metadata: &ServiceMetadata<'a>, type_argument_count: u32, compilation_mode: CompilationMode, + angular_version: Option, ) -> ServiceDefinition<'a> { let r3_metadata = metadata.to_r3_metadata(allocator, type_argument_count); - generate_service_definition(allocator, core_namespace, &r3_metadata, compilation_mode) + generate_service_definition( + allocator, + core_namespace, + &r3_metadata, + compilation_mode, + angular_version, + ) } /// Emit the ɵfac factory function. `@Service` factories never inject ctor @@ -124,7 +134,8 @@ mod tests { factory: None, }; - let def = generate_service_definition(&allocator, "i0", &metadata, CompilationMode::Full); + let def = + generate_service_definition(&allocator, "i0", &metadata, CompilationMode::Full, None); let emitter = JsEmitter::new(); let fac_js = emitter.emit_expression(&def.fac_definition); let prov_js = emitter.emit_expression(&def.prov_definition); diff --git a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs index c772828cd..ac4f10636 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -2139,7 +2139,7 @@ export class Parent {} assert!(!code.contains("import { LazyCmp }"), "Output:\n{code}"); assert!( compact.contains( - "ɵɵngDeclareClassMetadataAsync({minVersion:\"18.0.0\",version:\"0.0.0-PLACEHOLDER\",ngImport:i0,type:Parent,resolveDeferredDeps:()=>[import(\"./lazy\").then((m)=>m.LazyCmp)],resolveMetadata:(LazyCmp)=>" + "ɵɵngDeclareClassMetadataAsync({minVersion:\"18.0.0\",version:\"22.2.3\",ngImport:i0,type:Parent,resolveDeferredDeps:()=>[import(\"./lazy\").then((m)=>m.LazyCmp)],resolveMetadata:(LazyCmp)=>" ), "Output:\n{code}" ); diff --git a/crates/oxc_angular_compiler/tests/ngtsc_emit_parity_test.rs b/crates/oxc_angular_compiler/tests/ngtsc_emit_parity_test.rs index 0ff06d6ab..324b857b4 100644 --- a/crates/oxc_angular_compiler/tests/ngtsc_emit_parity_test.rs +++ b/crates/oxc_angular_compiler/tests/ngtsc_emit_parity_test.rs @@ -128,7 +128,7 @@ fn ng_module_schemas_are_left_out_of_the_partial_declaration() { let code = compile_partial(SCHEMAS_MODULE); assert_contains( &code, - "ɵɵngDeclareNgModule({minVersion:\"14.0.0\",version:\"0.0.0-PLACEHOLDER\",ngImport:i0,type:M})", + "ɵɵngDeclareNgModule({minVersion:\"14.0.0\",version:\"22.2.3\",ngImport:i0,type:M})", ); assert_contains(&code, "args:[{schemas:[NO_ERRORS_SCHEMA,CUSTOM_ELEMENTS_SCHEMA]}]"); } diff --git a/crates/oxc_angular_compiler/tests/partial_class_metadata_async_test.rs b/crates/oxc_angular_compiler/tests/partial_class_metadata_async_test.rs index 5c65c6c72..636b12d7e 100644 --- a/crates/oxc_angular_compiler/tests/partial_class_metadata_async_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_class_metadata_async_test.rs @@ -14,6 +14,7 @@ use oxc_angular_compiler::output::ast::{ LiteralArrayExpr, LiteralMapEntry, LiteralMapExpr, OutputExpression, ReadVarExpr, }; use oxc_angular_compiler::output::emitter::JsEmitter; +use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; use oxc_angular_compiler::{ CompilationMode, TransformOptions, compile_component_declare_class_metadata, compile_declare_class_metadata_async, transform_angular_file, @@ -74,7 +75,13 @@ fn async_class_metadata_emits_async_call_with_18_0_min_version() { is_default_import: false, }]; - let expr = compile_declare_class_metadata_async(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_declare_class_metadata_async( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); assert!( @@ -107,7 +114,13 @@ fn async_class_metadata_emits_resolve_deferred_deps_and_resolve_metadata() { }, ]; - let expr = compile_declare_class_metadata_async(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_declare_class_metadata_async( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); // resolveDeferredDeps: arrow returning dynamic imports for each dep. @@ -148,7 +161,13 @@ fn async_class_metadata_emits_null_for_missing_ctor_params() { is_default_import: false, }]; - let expr = compile_declare_class_metadata_async(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_declare_class_metadata_async( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); assert!( @@ -173,7 +192,13 @@ fn default_import_uses_m_default_in_resolver() { is_default_import: true, }]; - let expr = compile_declare_class_metadata_async(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_declare_class_metadata_async( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); assert!( @@ -188,7 +213,13 @@ fn dispatch_helper_falls_back_to_sync_when_no_deferred_deps() { let cmp_meta = make_class_metadata(&allocator, "MyCmp", "Component"); let empty: [R3DeferPerComponentDependency<'_>; 0] = []; - let expr = compile_component_declare_class_metadata(&allocator, "i0", &cmp_meta, &empty); + let expr = compile_component_declare_class_metadata( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &empty, + ); let js = emit(&expr); // Sync form — NOT async. @@ -216,7 +247,13 @@ fn dispatch_helper_picks_async_when_deferred_deps_present() { is_default_import: false, }]; - let expr = compile_component_declare_class_metadata(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_component_declare_class_metadata( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); assert!( @@ -317,7 +354,13 @@ fn unit_field_order_matches_upstream() { import_path: Ident::from("./lazy"), is_default_import: false, }]; - let expr = compile_declare_class_metadata_async(&allocator, "i0", &cmp_meta, &deps); + let expr = compile_declare_class_metadata_async( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &cmp_meta, + &deps, + ); let js = emit(&expr); let positions = [ diff --git a/crates/oxc_angular_compiler/tests/partial_directive_test.rs b/crates/oxc_angular_compiler/tests/partial_directive_test.rs index 2891083d5..4d3e9ae84 100644 --- a/crates/oxc_angular_compiler/tests/partial_directive_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_directive_test.rs @@ -2,6 +2,7 @@ //! (`ɵɵngDeclareDirective`). use oxc_allocator::{Allocator, Box, Vec}; +use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; use oxc_angular_compiler::{ CompilationMode, TransformOptions, compile_declare_directive_from_metadata, directive::{ @@ -35,7 +36,8 @@ fn minimal_directive_emits_required_fields_only() { .selector(Ident::from("[myDir]")) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"minVersion:"14.0.0""#), "base minVersion 14.0.0, got: {js}"); @@ -59,7 +61,8 @@ fn legacy_inputs_string_when_names_match() { .add_input(R3InputMetadata::simple(Ident::from("value"))) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"inputs:{value:"value"}"#), "expected string-shape input, got: {js}"); @@ -78,7 +81,8 @@ fn legacy_inputs_tuple_when_names_differ() { .add_input(input) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); let compact: String = js.chars().filter(|c| !c.is_whitespace()).collect(); @@ -98,7 +102,8 @@ fn signal_input_uses_new_format_and_bumps_minversion_17_1() { .add_input(R3InputMetadata::signal(Ident::from("value"))) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"minVersion:"17.1.0""#), "expected bumped minVersion 17.1.0, got: {js}"); @@ -126,7 +131,8 @@ fn signal_query_bumps_minversion_17_2() { .add_view_query(q) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"minVersion:"17.2.0""#), "expected bumped minVersion 17.2.0, got: {js}"); @@ -148,7 +154,8 @@ fn host_listeners_and_properties_emitted_raw() { .host(host) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); // Listeners and properties carried as raw unparsed strings — the linker @@ -224,7 +231,12 @@ fn host_directive_with_forward_ref_wraps_directive() { v }, }; - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &manual_meta); + let expr = compile_declare_directive_from_metadata( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &manual_meta, + ); let js = emit(&expr); assert!( js.contains("forwardRef(function") && js.contains("return FwdHostDir"), @@ -260,7 +272,8 @@ fn outputs_emitted_as_object_map() { is_signal: false, host_directives: Vec::new_in(&&allocator), }; - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!( js.contains(r#"outputs:{valueChange:"valueChange",internalEvent:"publicEvent"}"#), @@ -304,7 +317,8 @@ fn directive_uses_inheritance_and_on_changes() { is_signal: false, host_directives: Vec::new_in(&&allocator), }; - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &manual); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &manual); let js = emit(&expr); assert!(js.contains("usesInheritance:true"), "expected usesInheritance, got: {js}"); assert!(js.contains("usesOnChanges:true"), "expected usesOnChanges, got: {js}"); @@ -337,7 +351,8 @@ fn directive_export_as_emitted_as_string_array() { is_signal: false, host_directives: Vec::new_in(&&allocator), }; - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"exportAs:["alias1","alias2"]"#), "expected exportAs array, got: {js}"); } @@ -352,7 +367,8 @@ fn ng_import_is_last_field() { .selector(Ident::from("[myDir]")) .build() .unwrap(); - let expr = compile_declare_directive_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_directive_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); let ng_idx = js.find("ngImport").expect("ngImport should be present"); let selector_idx = js.find("selector:").expect("selector should be present"); diff --git a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs index a86d7f065..80d88e29e 100644 --- a/crates/oxc_angular_compiler/tests/partial_e2e_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_e2e_test.rs @@ -42,8 +42,8 @@ export class MyService {} // Both declarations carry the partial version envelope. assert!( - code.contains("minVersion") && code.contains("\"0.0.0-PLACEHOLDER\""), - "expected minVersion + placeholder version, got:\n{code}" + code.contains("minVersion") && code.contains("\"22.2.3\""), + "expected minVersion + release version, got:\n{code}" ); // Round-trip the entire emitted file through our linker — it should @@ -258,7 +258,7 @@ export class S3 {} ); assert!( partial.contains( - "i0.ɵɵngDeclareService({minVersion:\"22.0.0\",version:\"0.0.0-PLACEHOLDER\",ngImport:i0,type:S2,autoProvided:false})" + "i0.ɵɵngDeclareService({minVersion:\"22.0.0\",version:\"22.2.3\",ngImport:i0,type:S2,autoProvided:false})" ), "autoProvided: false should be declared, got:\n{partial}" ); diff --git a/crates/oxc_angular_compiler/tests/partial_factory_test.rs b/crates/oxc_angular_compiler/tests/partial_factory_test.rs index 730b5b37f..43bd41ec2 100644 --- a/crates/oxc_angular_compiler/tests/partial_factory_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_factory_test.rs @@ -12,6 +12,7 @@ use oxc_angular_compiler::factory::{ }; use oxc_angular_compiler::output::ast::{OutputExpression, ReadVarExpr}; use oxc_angular_compiler::output::emitter::JsEmitter; +use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; use oxc_str::Ident; fn read_var<'a>(allocator: &'a Allocator, name: &'static str) -> OutputExpression<'a> { @@ -50,7 +51,7 @@ fn simple_injectable_factory_with_one_dep() { let meta = make_meta(&allocator, "MyService", R3FactoryDeps::Valid(deps), FactoryTarget::Injectable); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); insta::assert_snapshot!(emit(&expr)); } @@ -59,7 +60,7 @@ fn simple_injectable_factory_with_one_dep() { fn factory_with_no_deps_emits_null() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "MyService", R3FactoryDeps::None, FactoryTarget::Injectable); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("deps:null"), "deps should be null, got: {js}"); @@ -71,7 +72,7 @@ fn factory_with_invalid_deps_emits_string_invalid() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "MyService", R3FactoryDeps::Invalid, FactoryTarget::Injectable); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"deps:"invalid""#), "deps should be \"invalid\", got: {js}"); @@ -90,7 +91,7 @@ fn factory_with_type_only_invalid_dep_coerces_to_invalid() { let meta = make_meta(&allocator, "MyService", R3FactoryDeps::Valid(deps), FactoryTarget::Injectable); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"deps:"invalid""#), "deps should coerce to \"invalid\", got: {js}"); @@ -111,7 +112,7 @@ fn factory_with_flag_deps_emits_only_set_flags() { R3FactoryDeps::Valid(deps), FactoryTarget::Injectable, ); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); // Set flags appear. @@ -130,7 +131,7 @@ fn factory_with_flag_deps_emits_only_set_flags() { fn factory_target_pipe() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "TitlePipe", R3FactoryDeps::None, FactoryTarget::Pipe); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("ɵɵFactoryTarget.Pipe"), "expected Pipe target, got: {js}"); @@ -140,7 +141,7 @@ fn factory_target_pipe() { fn factory_target_directive() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "MyDir", R3FactoryDeps::None, FactoryTarget::Directive); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("ɵɵFactoryTarget.Directive"), "expected Directive target, got: {js}"); @@ -150,7 +151,7 @@ fn factory_target_directive() { fn factory_target_component() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "MyCmp", R3FactoryDeps::None, FactoryTarget::Component); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("ɵɵFactoryTarget.Component"), "expected Component target, got: {js}"); @@ -160,7 +161,7 @@ fn factory_target_component() { fn factory_target_ng_module() { let allocator = Allocator::default(); let meta = make_meta(&allocator, "MyMod", R3FactoryDeps::None, FactoryTarget::NgModule); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("ɵɵFactoryTarget.NgModule"), "expected NgModule target, got: {js}"); @@ -180,7 +181,7 @@ fn round_trip_through_linker_to_full_factory() { let meta = make_meta(&allocator, "MyService", R3FactoryDeps::Valid(deps), FactoryTarget::Injectable); - let expr = compile_declare_factory_function(&allocator, "i0", &meta); + let expr = compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let source = format!( "import * as i0 from \"@angular/core\";\nexport class MyService {{}}\nMyService.\u{0275}fac = {};", diff --git a/crates/oxc_angular_compiler/tests/partial_injectable_test.rs b/crates/oxc_angular_compiler/tests/partial_injectable_test.rs index f782efde5..419534b41 100644 --- a/crates/oxc_angular_compiler/tests/partial_injectable_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_injectable_test.rs @@ -11,6 +11,7 @@ use oxc_angular_compiler::factory::R3DependencyMetadata; use oxc_angular_compiler::injectable::R3InjectableMetadataBuilder; use oxc_angular_compiler::output::ast::{OutputExpression, ReadVarExpr}; use oxc_angular_compiler::output::emitter::JsEmitter; +use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; use oxc_str::Ident; fn read_var<'a>(allocator: &'a Allocator, name: &'static str) -> OutputExpression<'a> { @@ -33,7 +34,8 @@ fn simple_root_injectable() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); insta::assert_snapshot!(emit(&expr)); } @@ -47,7 +49,8 @@ fn injectable_with_use_class() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!( @@ -70,7 +73,8 @@ fn injectable_with_use_class_forward_ref_wraps() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!( @@ -90,7 +94,8 @@ fn injectable_with_use_existing() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("useExisting:RealToken"), "expected useExisting:RealToken, got: {js}"); @@ -107,7 +112,8 @@ fn injectable_with_use_value() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("useValue:configObject"), "expected useValue:configObject, got: {js}"); @@ -130,7 +136,8 @@ fn injectable_with_use_factory_and_deps() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("useFactory:createService"), "useFactory should be raw, got: {js}"); @@ -150,7 +157,8 @@ fn provided_in_omitted_when_none() { .r#type(read_var(&allocator, "ScopedService")) .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(!js.contains("providedIn"), "providedIn should be omitted when None, got: {js}"); @@ -165,7 +173,8 @@ fn provided_in_platform() { .provided_in_platform() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"providedIn:"platform""#), "expected providedIn:\"platform\", got: {js}"); @@ -180,7 +189,8 @@ fn provided_in_any() { .provided_in_any() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"providedIn:"any""#), "expected providedIn:\"any\", got: {js}"); @@ -195,7 +205,8 @@ fn provided_in_module() { .provided_in_module(read_var(&allocator, "FeatureModule")) .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!( @@ -213,7 +224,8 @@ fn injectable_field_order_matches_upstream() { .provided_in_root() .build() .unwrap(); - let expr = compile_declare_injectable_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_injectable_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); let min_idx = js.find("minVersion").unwrap(); @@ -248,7 +260,12 @@ fn round_trip_partial_injectable_and_factory_through_linker() { .provided_in_root() .build() .unwrap(); - let prov_expr = compile_declare_injectable_from_metadata(&allocator, "i0", &injectable_meta); + let prov_expr = compile_declare_injectable_from_metadata( + &allocator, + "i0", + PLACEHOLDER_VERSION, + &injectable_meta, + ); let factory_meta = R3FactoryMetadata::Constructor(R3ConstructorFactoryMetadata { name: Ident::from("ApiService"), @@ -262,7 +279,8 @@ fn round_trip_partial_injectable_and_factory_through_linker() { }, target: FactoryTarget::Injectable, }); - let fac_expr = compile_declare_factory_function(&allocator, "i0", &factory_meta); + let fac_expr = + compile_declare_factory_function(&allocator, "i0", PLACEHOLDER_VERSION, &factory_meta); let source = format!( "import * as i0 from \"@angular/core\";\nexport class ApiService {{}}\nApiService.\u{0275}fac = {};\nApiService.\u{0275}prov = {};", diff --git a/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs new file mode 100644 index 000000000..521f24b92 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -0,0 +1,767 @@ +//! NgModule scopes in partial compilation (#655): a component an NgModule +//! declares gets its compilation scope from `ɵɵsetComponentScope` in the +//! module's file, and a standalone component's `dependencies` list what the +//! NgModules it imports export, when cross-file analysis +//! (`TransformOptions::resolve_imported_values`) reads them. + +#![cfg(feature = "cross_file_elision")] + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{ + AngularVersion, CompilationMode, TransformOptions, TransformResult, transform_angular_file, +}; +use tempfile::TempDir; + +fn write(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, content).unwrap(); + path +} + +fn partial() -> TransformOptions { + TransformOptions { + compilation_mode: CompilationMode::Partial, + resolve_imported_values: true, + ..Default::default() + } +} + +fn compile(dir: &TempDir, name: &str, source: &str, options: &TransformOptions) -> TransformResult { + let path = write(dir, name, source); + let allocator = Allocator::default(); + let result = + transform_angular_file(&allocator, path.to_str().unwrap(), source, Some(options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + result +} + +fn compact(code: &str) -> String { + code.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// `src/dep.ts`: a directive and a pipe its NgModule declares and exports. +fn dep_module(dir: &TempDir) { + write( + dir, + "src/dep.ts", + r" +import { Directive, NgModule, Pipe } from '@angular/core'; +@Directive({ selector: '[d]', standalone: false }) export class D {} +@Pipe({ name: 'p', standalone: false }) export class P { transform(v: unknown) { return v; } } +@NgModule({ declarations: [D, P], exports: [D, P] }) export class DepModule {} +", + ); +} + +/// `node_modules/@lib/ui`: a package whose declaration files are laid out +/// like Angular's own: an entry re-exporting a chunk, an alias, a module +/// exporting another module, and `forRoot()`. +fn ui_package(dir: &TempDir) { + write( + dir, + "node_modules/@lib/ui/package.json", + r#"{ "name": "@lib/ui", "exports": { ".": { "types": "./types/ui.d.ts", "default": "./fesm/ui.mjs" } } }"#, + ); + write(dir, "node_modules/@lib/ui/fesm/ui.mjs", "export {};\n"); + write( + dir, + "node_modules/@lib/ui/types/ui.d.ts", + "export { UiDir as UiAlias, UiDir, UiPipe, UiModule } from './_chunk.js';\n", + ); + write( + dir, + "node_modules/@lib/ui/types/_chunk.d.ts", + r#" +import * as i0 from '@angular/core'; +import * as i1 from './_inner.js'; +declare class UiDir { + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +declare class UiPipe { + static ɵpipe: i0.ɵɵPipeDeclaration; +} +declare class UiModule { + static forRoot(): i0.ModuleWithProviders; + static ɵmod: i0.ɵɵNgModuleDeclaration; +} +export { UiDir, UiPipe, UiModule }; +"#, + ); + write( + dir, + "node_modules/@lib/ui/types/_inner.d.ts", + r#" +import * as i0 from '@angular/core'; +export declare class InnerDir { + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class InnerModule { + static ɵmod: i0.ɵɵNgModuleDeclaration; +} +"#, + ); +} + +#[test] +fn module_sets_the_scope_of_the_component_it_declares() { + let dir = TempDir::new().unwrap(); + dep_module(&dir); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule } from '@angular/core'; +import { DepModule } from './dep.js'; +@Component({ selector: 'app', template: '
{{ 1 | p }}
', standalone: false }) +export class App {} +@NgModule({ declarations: [App], imports: [DepModule] }) +export class AppModule {} +", + &partial(), + ); + let code = compact(&result.code); + // The file's own `./dep.js` import names the module the classes come from. + assert!(code.contains("import*asi1from'./dep.js';"), "{}", result.code); + assert!( + code.contains("i0.ɵɵsetComponentScope(App,()=>[i1.D,App],()=>[i1.P]);"), + "{}", + result.code + ); + // At the end of the file, once every class in it exists. + assert!(code.ends_with("()=>[i1.P]);"), "{}", result.code); + // The files read are watched. + assert!( + result.dependencies.iter().any(|file| file.ends_with("dep.ts")), + "{:?}", + result.dependencies + ); +} + +#[test] +fn declaration_file_scopes_are_read_through_the_entry_point() { + let dir = TempDir::new().unwrap(); + ui_package(&dir); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule } from '@angular/core'; +import { UiModule } from '@lib/ui'; +@Component({ selector: 'app', template: '
', standalone: false }) +export class App {} +@NgModule({ declarations: [App], imports: [UiModule.forRoot()] }) +export class AppModule {} +", + &partial(), + ); + let code = compact(&result.code); + // `UiDir` by its own name, not the `UiAlias` listed first. `InnerDir`, + // which the entry point doesn't export, makes the scope unreadable. + assert!(!code.contains("UiAlias"), "{}", result.code); + assert!(!code.contains("ɵɵsetComponentScope"), "{}", result.code); + // Package files aren't watched. + assert!( + !result.dependencies.iter().any(|file| file.contains("node_modules")), + "{:?}", + result.dependencies + ); +} + +#[test] +fn declaration_file_scope_with_nested_module() { + let dir = TempDir::new().unwrap(); + ui_package(&dir); + // The entry point exports the nested module's directive too. + write( + &dir, + "node_modules/@lib/ui/types/ui.d.ts", + "export { UiDir as UiAlias, UiDir, UiPipe, UiModule } from './_chunk.js';\nexport * from './_inner.js';\n", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule } from '@angular/core'; +import * as ui from '@lib/ui'; +@Component({ selector: 'app', template: '
', standalone: false }) +export class App {} +@NgModule({ declarations: [App], imports: [ui.UiModule.forRoot()] }) +export class AppModule {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "i0.ɵɵsetComponentScope(App,()=>[i1.UiDir,i1.InnerDir,App],()=>[i1.UiPipe]);" + ), + "{}", + result.code + ); +} + +#[test] +fn lists_barrels_and_forward_refs_are_followed() { + let dir = TempDir::new().unwrap(); + dep_module(&dir); + write(&dir, "src/index.ts", "export * from './dep';\nexport { SHARED } from './shared';\n"); + write( + &dir, + "src/shared.ts", + r" +import { DepModule } from './dep'; +export const SHARED = [DepModule]; +", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule, forwardRef } from '@angular/core'; +import { SHARED } from './index'; +@Component({ selector: 'a-cmp', template: '', standalone: false }) export class A {} +@Component({ selector: 'b-cmp', template: '', standalone: false }) export class B {} +const COMPONENTS = [A, forwardRef(() => B)]; +@NgModule({ declarations: [...COMPONENTS], imports: [SHARED] }) +export class AppModule {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains("i0.ɵɵsetComponentScope(A,()=>[i1.D,A,B],()=>[i1.P]);"), + "{}", + result.code + ); + assert!( + code.contains("i0.ɵɵsetComponentScope(B,()=>[i1.D,A,B],()=>[i1.P]);"), + "{}", + result.code + ); + assert!(code.contains("import*asi1from'./dep';"), "{}", result.code); +} + +#[test] +fn an_unreadable_scope_leaves_the_module_as_before() { + let dir = TempDir::new().unwrap(); + for imports in ["[Missing]", "[makeModules()]", "[UnknownPkgModule]"] { + let source = format!( + r" +import {{ Component, NgModule }} from '@angular/core'; +import {{ UnknownPkgModule }} from 'unknown-pkg'; +declare const Missing: any; +declare function makeModules(): any[]; +@Component({{ selector: 'app', template: '', standalone: false }}) export class App {{}} +@NgModule({{ declarations: [App], imports: {imports} }}) +export class AppModule {{}} +" + ); + let result = compile(&dir, "src/app.ts", &source, &partial()); + assert!(!result.code.contains("ɵɵsetComponentScope"), "{imports}: {}", result.code); + } +} + +#[test] +fn a_module_cycle_leaves_the_module_as_before() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/cycle.ts", + r" +import { NgModule, forwardRef } from '@angular/core'; +@NgModule({ exports: [forwardRef(() => BModule)] }) export class AModule {} +@NgModule({ exports: [AModule] }) export class BModule {} +", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule } from '@angular/core'; +import { AModule } from './cycle'; +@Component({ selector: 'app', template: '', standalone: false }) export class App {} +@NgModule({ declarations: [App], imports: [AModule] }) +export class AppModule {} +", + &partial(), + ); + assert!(!result.code.contains("ɵɵsetComponentScope"), "{}", result.code); +} + +#[test] +fn only_partial_compilation_with_imported_values_sets_scopes() { + let dir = TempDir::new().unwrap(); + dep_module(&dir); + let source = r" +import { Component, NgModule } from '@angular/core'; +import { DepModule } from './dep'; +@Component({ selector: 'app', template: '
', standalone: false }) export class App {} +@NgModule({ declarations: [App], imports: [DepModule] }) +export class AppModule {} +"; + let full = TransformOptions { compilation_mode: CompilationMode::Full, ..partial() }; + let no_values = TransformOptions { resolve_imported_values: false, ..partial() }; + for options in [full, no_values] { + let result = compile(&dir, "src/app.ts", source, &options); + assert!(!result.code.contains("ɵɵsetComponentScope"), "{}", result.code); + } +} + +#[test] +fn standalone_component_lists_what_an_imported_module_exports() { + let dir = TempDir::new().unwrap(); + dep_module(&dir); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component } from '@angular/core'; +import { DepModule } from './dep'; +@Component({ selector: 'app', template: '
{{ 1 | p }}
', imports: [DepModule] }) +export class App {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "dependencies:[{kind:\"ngmodule\",type:DepModule},{kind:\"directive\",type:i1.D,selector:\"[d]\"},{kind:\"pipe\",type:i1.P,name:\"p\"}]" + ), + "{}", + result.code + ); +} + +#[test] +fn standalone_component_keeps_an_unreadable_import_as_before() { + let dir = TempDir::new().unwrap(); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component } from '@angular/core'; +import { SomeModule } from 'unknown-pkg'; +@Component({ selector: 'app', template: '', imports: [SomeModule] }) +export class App {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains("dependencies:[{kind:\"directive\",type:SomeModule,selector:\"*\"}]"), + "{}", + result.code + ); +} + +#[test] +fn declarations_carry_the_angular_version_compiled_for() { + let dir = TempDir::new().unwrap(); + let source = r" +import { Component } from '@angular/core'; +@Component({ selector: 'app', template: '@if (true) { x }' }) +export class App {} +"; + let latest = compile(&dir, "src/app.ts", source, &partial()); + assert!(compact(&latest.code).contains("version:\"22.2.3\""), "{}", latest.code); + assert!(!latest.code.contains("PLACEHOLDER"), "{}", latest.code); + let v20 = + TransformOptions { angular_version: Some(AngularVersion::new(20, 1, 4)), ..partial() }; + let result = compile(&dir, "src/app.ts", source, &v20); + assert!(compact(&result.code).contains("version:\"20.1.4\""), "{}", result.code); + assert!(!result.code.contains("22.2.3"), "{}", result.code); + assert!(!result.code.contains("isStandalone"), "{}", result.code); + // Angular's linker reads a missing `isStandalone` as `false` before v19. + let v18 = + TransformOptions { angular_version: Some(AngularVersion::new(18, 2, 0)), ..partial() }; + let standalone = source.replace("selector: 'app',", "selector: 'app', standalone: true,"); + let result = compile(&dir, "src/app.ts", &standalone, &v18); + assert!(compact(&result.code).contains("isStandalone:true"), "{}", result.code); +} + +#[test] +fn standalone_component_lists_each_class_once() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/shared.ts", + r" +import { Directive, NgModule, Pipe } from '@angular/core'; +@Directive({ selector: '[s]' }) export class S {} +@Pipe({ name: 'q' }) export class Q { transform(v: unknown) { return v; } } +@NgModule({ imports: [S, Q], exports: [S, Q] }) export class AModule {} +@NgModule({ imports: [S, Q], exports: [S, Q] }) export class BModule {} +", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component } from '@angular/core'; +import { AModule, BModule, S } from './shared'; +@Component({ selector: 'app', template: '{{ 1 | q }}', imports: [S, AModule, BModule] }) +export class App {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "dependencies:[{kind:\"directive\",type:S,selector:\"*\"},{kind:\"ngmodule\",type:AModule},{kind:\"pipe\",type:i1.Q,name:\"q\"},{kind:\"ngmodule\",type:BModule}]" + ), + "{}", + result.code + ); +} + +#[test] +fn a_component_in_an_import_cycle_with_its_module_gets_no_scope() { + let dir = TempDir::new().unwrap(); + dep_module(&dir); + // `cycle.ts` reaches the module file through `token.ts`, so it may run + // after it: the module file can't read `Cycle` when it runs. + write(&dir, "src/token.ts", "export { TOKEN } from './app.module';\n"); + write( + &dir, + "src/cycle.ts", + r" +import { Component } from '@angular/core'; +import { TOKEN } from './token'; +@Component({ selector: 'cycle', template: '{{ token }}', standalone: false }) +export class Cycle { token = TOKEN; } +", + ); + write( + &dir, + "src/plain.ts", + r" +import { Component } from '@angular/core'; +@Component({ selector: 'plain', template: '', standalone: false }) export class Plain {} +", + ); + let result = compile( + &dir, + "src/app.module.ts", + r" +import { NgModule } from '@angular/core'; +import { DepModule } from './dep'; +import { Cycle } from './cycle'; +import { Plain } from './plain'; +export const TOKEN = 'token'; +@NgModule({ declarations: [Cycle, Plain], imports: [DepModule] }) +export class AppModule {} +", + &partial(), + ); + let code = compact(&result.code); + assert_eq!(code.matches("ɵɵsetComponentScope").count(), 1, "{}", result.code); + assert!( + code.contains("ɵɵsetComponentScope(i1.Plain,()=>[i2.D,i3.Cycle,i1.Plain],()=>[i2.P]);"), + "{}", + result.code + ); +} + +#[test] +fn export_star_cycles_end() { + use std::fmt::Write as _; + // Every file re-exports the next two and the first: a search that + // follows each path separately takes exponential time. + const FILES: usize = 40; + let dir = TempDir::new().unwrap(); + for i in 0..FILES { + let mut source = String::from("export * from './f0';\n"); + for next in [i + 1, i + 2] { + if next < FILES { + writeln!(source, "export * from './f{next}';").unwrap(); + } + } + if i == FILES - 1 { + source.push_str( + "import { NgModule } from '@angular/core';\n@NgModule({}) export class EndModule {}\n", + ); + } + write(&dir, &format!("src/f{i}.ts"), &source); + } + let source = r" +import { Component, NgModule } from '@angular/core'; +import { EndModule, Missing } from './f0'; +@Component({ selector: 'app', template: '', standalone: false }) export class App {} +@NgModule({ declarations: [App], imports: [EndModule] }) export class AModule {} +@NgModule({ declarations: [App], imports: [Missing] }) export class BModule {} +"; + let result = compile(&dir, "src/app.ts", source, &partial()); + let code = compact(&result.code); + assert!(code.contains("ɵɵsetComponentScope(App,()=>[App],()=>[]);"), "{}", result.code); + assert_eq!(code.matches("ɵɵsetComponentScope").count(), 1, "{}", result.code); +} + +#[test] +fn a_workspace_library_class_its_entry_point_does_not_export_is_imported_by_path() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "tsconfig.json", + r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "@ws/lib": ["libs/lib/index.ts"] } } }"#, + ); + write(&dir, "libs/lib/index.ts", "export { LibModule } from './lib.module';\n"); + write( + &dir, + "libs/lib/lib.module.ts", + r" +import { Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[lib]', standalone: false }) export class LibDir {} +@NgModule({ declarations: [LibDir], exports: [LibDir] }) export class LibModule {} +", + ); + let options = + TransformOptions { tsconfig_path: Some(dir.path().join("tsconfig.json")), ..partial() }; + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, NgModule } from '@angular/core'; +import { LibModule } from '@ws/lib'; +@Component({ selector: 'app', template: '', standalone: false }) export class App {} +@NgModule({ declarations: [App], imports: [LibModule] }) export class AppModule {} +", + &options, + ); + let code = compact(&result.code); + assert!(code.contains("import*asi1from'../libs/lib/lib.module';"), "{}", result.code); + assert!( + code.contains("ɵɵsetComponentScope(App,()=>[i1.LibDir,App],()=>[]);"), + "{}", + result.code + ); +} + +#[test] +fn a_module_with_an_unnamed_pipe_hides_none_of_its_classes() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/shared.ts", + r" +import { Directive, NgModule, Pipe } from '@angular/core'; +declare function pipeName(): string; +@Directive({ selector: '[s]' }) export class S {} +@Pipe({ name: pipeName() }) export class Q { transform(v: unknown) { return v; } } +@NgModule({ imports: [S, Q], exports: [S, Q] }) export class AModule {} +@NgModule({ imports: [S], exports: [S] }) export class BModule {} +", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component } from '@angular/core'; +import { AModule, BModule } from './shared'; +@Component({ selector: 'app', template: '', imports: [AModule, BModule] }) +export class App {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "dependencies:[{kind:\"directive\",type:AModule,selector:\"*\"},{kind:\"ngmodule\",type:BModule},{kind:\"directive\",type:i1.S,selector:\"[s]\"}]" + ), + "{}", + result.code + ); +} + +/// `src/app.module.ts` declaring `C` from `src/c.ts`, whose source is `c`. +fn module_declaring_c(dir: &TempDir, c: &str) -> String { + write(dir, "src/c.ts", c); + let result = compile( + dir, + "src/app.module.ts", + r" +import { NgModule } from '@angular/core'; +import { C } from './c'; +export interface Config {} +export const TOKEN = 'token'; +@NgModule({ declarations: [C] }) +export class AppModule {} +", + &partial(), + ); + compact(&result.code) +} + +#[test] +fn a_type_only_import_of_the_module_file_is_no_cycle() { + for import in [ + "import type { Config } from './app.module';", + "import { type Config } from './app.module';", + "export type { Config } from './app.module';", + ] { + let dir = TempDir::new().unwrap(); + let code = module_declaring_c( + &dir, + &format!( + "import {{ Component }} from '@angular/core';\n{import}\n@Component({{ selector: 'c', template: '', standalone: false }}) export class C {{}}\n" + ), + ); + assert!(code.contains("ɵɵsetComponentScope(i1.C,()=>[i1.C],()=>[]);"), "{import}: {code}"); + } +} + +#[test] +fn a_namespace_re_export_of_the_module_file_is_a_cycle() { + let dir = TempDir::new().unwrap(); + write(&dir, "src/reexp.ts", "export * as mod from './app.module';\n"); + let code = module_declaring_c( + &dir, + r" +import { Component } from '@angular/core'; +import { mod } from './reexp'; +@Component({ selector: 'c', template: '', standalone: false }) +export class C { token = mod.TOKEN; } +", + ); + assert!(!code.contains("ɵɵsetComponentScope"), "{code}"); +} + +#[test] +fn a_cycle_through_the_barrel_the_component_is_imported_from_is_one() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "tsconfig.json", + r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "@app/shared": ["src/shared/index.ts"] } } }"#, + ); + write(&dir, "src/shared/index.ts", "export * from './shared.module';\nexport * from './c';\n"); + write( + &dir, + "src/shared/c.ts", + r" +import { Component } from '@angular/core'; +@Component({ selector: 'c', template: '', standalone: false }) export class C {} +", + ); + let options = + TransformOptions { tsconfig_path: Some(dir.path().join("tsconfig.json")), ..partial() }; + let result = compile( + &dir, + "src/shared/shared.module.ts", + r" +import { NgModule } from '@angular/core'; +import { C } from '@app/shared'; +@NgModule({ declarations: [C], exports: [C] }) +export class SharedModule {} +", + &options, + ); + assert!(!result.code.contains("ɵɵsetComponentScope"), "{}", result.code); +} + +#[test] +fn a_class_declared_after_the_component_is_a_forward_reference() { + let dir = TempDir::new().unwrap(); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component, Directive, NgModule, forwardRef } from '@angular/core'; +@Directive({ selector: '[e]' }) export class Early {} +@NgModule({ imports: [Early, forwardRef(() => Late)], exports: [Early, forwardRef(() => Late)] }) +export class M {} +@Component({ selector: 'app', template: '', imports: [M] }) +export class App {} +@Directive({ selector: '[l]' }) export class Late {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "dependencies:[{kind:\"ngmodule\",type:M},{kind:\"directive\",type:Early,selector:\"[e]\"},{kind:\"directive\",type:i0.forwardRef(function(){returnLate;}),selector:\"[l]\"}]" + ), + "{}", + result.code + ); +} + +#[test] +fn a_class_whose_file_loads_the_component_file_is_a_forward_reference() { + let dir = TempDir::new().unwrap(); + // Loading `d.ts` runs `app.ts` before `D` exists. + write( + &dir, + "src/d.ts", + r" +import { Directive } from '@angular/core'; +import { M } from './m'; +import { App } from './app'; +@Directive({ selector: '[d]' }) export class D {} +export const all = [M, App]; +", + ); + write( + &dir, + "src/m.ts", + r" +import { NgModule, forwardRef } from '@angular/core'; +import { D } from './d'; +@NgModule({ imports: [forwardRef(() => D)], exports: [forwardRef(() => D)] }) +export class M {} +", + ); + let result = compile( + &dir, + "src/app.ts", + r" +import { Component } from '@angular/core'; +import { M } from './m'; +@Component({ selector: 'app', template: '', imports: [M] }) +export class App {} +", + &partial(), + ); + let code = compact(&result.code); + assert!( + code.contains( + "{kind:\"directive\",type:i0.forwardRef(function(){returni1.D;}),selector:\"[d]\"}" + ), + "{}", + result.code + ); +} + +#[test] +fn a_class_imported_by_path_from_an_mts_file_is_imported_as_mjs() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/d.mts", + r" +import { Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[d]' }) export class D {} +", + ); + write( + &dir, + "src/m.mts", + r" +import { NgModule } from '@angular/core'; +import { D } from './d.mjs'; +@NgModule({ imports: [D], exports: [D] }) export class M {} +", + ); + let result = compile( + &dir, + "src/app.mts", + r" +import { Component } from '@angular/core'; +import { M } from './m.mjs'; +@Component({ selector: 'app', template: '', imports: [M] }) +export class App {} +", + &partial(), + ); + assert!(compact(&result.code).contains("import*asi1from'./d.mjs';"), "{}", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/partial_ng_module_test.rs b/crates/oxc_angular_compiler/tests/partial_ng_module_test.rs index 994b305c6..ddc501c6d 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_test.rs @@ -2,6 +2,7 @@ //! (`ɵɵngDeclareNgModule` + `ɵɵngDeclareInjector`). use oxc_allocator::{Allocator, Box, Vec}; +use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; use oxc_angular_compiler::{ CompilationMode, TransformOptions, compile_declare_injector_from_metadata, compile_declare_ng_module_from_metadata, @@ -33,7 +34,8 @@ fn empty_module_emits_only_type() { .selector_scope_mode(R3SelectorScopeMode::Omit) .build() .unwrap(); - let expr = compile_declare_ng_module_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_ng_module_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("type:EmptyModule"), "expected type:EmptyModule, got: {js}"); @@ -62,7 +64,8 @@ fn module_with_declarations_and_imports() { .add_import(R3Reference::value_only(read_var(&allocator, "CommonModule"))) .build() .unwrap(); - let expr = compile_declare_ng_module_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_ng_module_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("declarations:[MyComp,MyDir]"), "expected plain array, got: {js}"); @@ -84,7 +87,8 @@ fn module_with_forward_decls_wraps_lists_in_arrow() { .add_export(R3Reference::value_only(read_var(&allocator, "LaterComp"))) .build() .unwrap(); - let expr = compile_declare_ng_module_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_ng_module_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); // Normalize whitespace and search — emitter wraps lines and adjusts @@ -108,7 +112,8 @@ fn module_with_bootstrap_and_schemas() { .add_schema(R3Reference::value_only(read_var(&allocator, "CUSTOM_ELEMENTS_SCHEMA"))) .build() .unwrap(); - let expr = compile_declare_ng_module_from_metadata(&allocator, "i0", &meta); + let expr = + compile_declare_ng_module_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("bootstrap:[AppComponent]"), "expected bootstrap, got: {js}"); @@ -125,7 +130,7 @@ fn injector_with_providers_only() { .providers(providers) .build() .unwrap(); - let expr = compile_declare_injector_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_injector_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("providers:MY_PROVIDERS"), "expected providers expr, got: {js}"); @@ -145,7 +150,7 @@ fn injector_without_providers_omits_field() { .r#type(read_var(&allocator, "EmptyModule")) .build() .unwrap(); - let expr = compile_declare_injector_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_injector_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(!js.contains("providers"), "providers field should be omitted entirely, got: {js}"); @@ -161,7 +166,7 @@ fn injector_with_imports_array() { .add_import(read_var(&allocator, "HttpClientModule")) .build() .unwrap(); - let expr = compile_declare_injector_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_injector_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!( @@ -186,7 +191,7 @@ fn injector_raw_imports_preserved_over_per_element_imports() { .raw_imports(raw_imports) .build() .unwrap(); - let expr = compile_declare_injector_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_injector_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("imports:EXTERNAL_IMPORTS_ARRAY"), "expected raw imports, got: {js}"); diff --git a/crates/oxc_angular_compiler/tests/partial_pipe_test.rs b/crates/oxc_angular_compiler/tests/partial_pipe_test.rs index 073f2e964..a9b22b56d 100644 --- a/crates/oxc_angular_compiler/tests/partial_pipe_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_pipe_test.rs @@ -1,6 +1,7 @@ //! Tests for the partial-declaration Pipe emitter (`ɵɵngDeclarePipe`). use oxc_allocator::{Allocator, Box}; +use oxc_angular_compiler::partial::{LATEST_VERSION, PLACEHOLDER_VERSION}; use oxc_angular_compiler::{ CompilationMode, TransformOptions, compile_declare_pipe_from_metadata, link, output::ast::{OutputExpression, ReadVarExpr}, @@ -32,7 +33,7 @@ fn standalone_pure_named_pipe_omits_optional_fields() { .pure(true) .is_standalone(true) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", LATEST_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"name:"upper""#), "expected name:\"upper\", got: {js}"); @@ -50,7 +51,7 @@ fn impure_pipe_emits_pure_false() { .pure(false) .is_standalone(true) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("pure:false"), "expected pure:false, got: {js}"); @@ -66,7 +67,7 @@ fn non_standalone_pipe_emits_isstandalone_false() { .pure(true) .is_standalone(false) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains("isStandalone:false"), "expected isStandalone:false, got: {js}"); @@ -86,7 +87,7 @@ fn pipe_uses_pipe_name_over_class_name() { .pure(true) .is_standalone(true) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"name:"currency""#), "expected name:\"currency\", got: {js}"); @@ -100,7 +101,7 @@ fn pipe_falls_back_to_class_name_when_pipe_name_missing() { .pure(true) .is_standalone(true) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); assert!(js.contains(r#"name:"MyPipe""#), "expected name:\"MyPipe\", got: {js}"); @@ -114,7 +115,7 @@ fn field_order_matches_upstream() { .pure(false) .is_standalone(false) .build(); - let expr = compile_declare_pipe_from_metadata(&allocator, "i0", &meta); + let expr = compile_declare_pipe_from_metadata(&allocator, "i0", PLACEHOLDER_VERSION, &meta); let js = emit(&expr); let min_idx = js.find("minVersion").unwrap(); @@ -208,3 +209,18 @@ export class ReversePipe {} result.code ); } + +#[test] +fn standalone_is_explicit_for_a_linker_before_v19() { + // Angular's linker reads a missing `isStandalone` as `false` before v19. + let allocator = Allocator::default(); + let meta = + R3PipeMetadataBuilder::new(Ident::from("UpperPipe"), read_var(&allocator, "UpperPipe")) + .pipe_name(Ident::from("upper")) + .is_standalone(true) + .build(); + let old = emit(&compile_declare_pipe_from_metadata(&allocator, "i0", "18.2.0", &meta)); + assert!(old.contains("isStandalone:true"), "got: {old}"); + let new = emit(&compile_declare_pipe_from_metadata(&allocator, "i0", "19.0.0", &meta)); + assert!(!new.contains("isStandalone"), "got: {new}"); +} diff --git a/crates/oxc_angular_compiler/tests/partial_upstream_parity_test.rs b/crates/oxc_angular_compiler/tests/partial_upstream_parity_test.rs index dca81b0df..b858e86a4 100644 --- a/crates/oxc_angular_compiler/tests/partial_upstream_parity_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_upstream_parity_test.rs @@ -109,7 +109,7 @@ fn hello_world_component_partial_emit_matches_upstream_shape() { assert!( contains_collapsed( &code, - r#"ɵɵngDeclareComponent({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",type:MyComponent,isStandalone:false,selector:"my-component""# + r#"ɵɵngDeclareComponent({minVersion:"14.0.0",version:"22.2.3",type:MyComponent,isStandalone:false,selector:"my-component""# ), "ɵcmp field order should match upstream (minVersion..type..isStandalone..selector). Got:\n{code}" ); @@ -212,7 +212,7 @@ export class MyPipe {} assert!( contains_collapsed( &code, - r#"ɵɵngDeclarePipe({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",ngImport:i0,type:MyPipe,isStandalone:false,name:"myPipe",pure:false})"# + r#"ɵɵngDeclarePipe({minVersion:"14.0.0",version:"22.2.3",ngImport:i0,type:MyPipe,isStandalone:false,name:"myPipe",pure:false})"# ), "Pipe partial shape should match upstream byte-for-byte (modulo whitespace). Got:\n{code}" ); @@ -235,7 +235,7 @@ export class MyService {} assert!( contains_collapsed( &code, - r#"ɵɵngDeclareInjectable({minVersion:"12.0.0",version:"0.0.0-PLACEHOLDER",ngImport:i0,type:MyService,providedIn:"root"})"# + r#"ɵɵngDeclareInjectable({minVersion:"12.0.0",version:"22.2.3",ngImport:i0,type:MyService,providedIn:"root"})"# ), "Injectable partial shape should match upstream. Got:\n{code}" ); @@ -263,7 +263,7 @@ export class MyService {} assert!( contains_collapsed( &code, - r#"ɵɵngDeclareClassMetadata({minVersion:"12.0.0",version:"0.0.0-PLACEHOLDER",ngImport:i0,type:MyService"# + r#"ɵɵngDeclareClassMetadata({minVersion:"12.0.0",version:"22.2.3",ngImport:i0,type:MyService"# ), "ClassMetadata partial field order should match upstream. Got:\n{code}" ); diff --git a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__legacy_inputs_string_when_names_match.snap b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__legacy_inputs_string_when_names_match.snap index 3df7e1a52..2e03d7edf 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__legacy_inputs_string_when_names_match.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__legacy_inputs_string_when_names_match.snap @@ -3,4 +3,4 @@ source: crates/oxc_angular_compiler/tests/partial_directive_test.rs expression: js --- i0.ɵɵngDeclareDirective({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",type:MyDir, - selector:"[myDir]",inputs:{value:"value"},ngImport:i0}) + isStandalone:true,selector:"[myDir]",inputs:{value:"value"},ngImport:i0}) diff --git a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__minimal_directive_emits_required_fields_only.snap b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__minimal_directive_emits_required_fields_only.snap index 8660c08e7..febbea295 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__minimal_directive_emits_required_fields_only.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__minimal_directive_emits_required_fields_only.snap @@ -3,4 +3,4 @@ source: crates/oxc_angular_compiler/tests/partial_directive_test.rs expression: js --- i0.ɵɵngDeclareDirective({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",type:MyDir, - selector:"[myDir]",ngImport:i0}) + isStandalone:true,selector:"[myDir]",ngImport:i0}) diff --git a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__signal_input_uses_new_format_and_bumps_minversion_17_1.snap b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__signal_input_uses_new_format_and_bumps_minversion_17_1.snap index 89d0a86e4..a3f45c6f3 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__signal_input_uses_new_format_and_bumps_minversion_17_1.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/partial_directive_test__signal_input_uses_new_format_and_bumps_minversion_17_1.snap @@ -3,5 +3,6 @@ source: crates/oxc_angular_compiler/tests/partial_directive_test.rs expression: js --- i0.ɵɵngDeclareDirective({minVersion:"17.1.0",version:"0.0.0-PLACEHOLDER",type:MyDir, - selector:"[myDir]",inputs:{value:{classPropertyName:"value",publicName:"value", - isSignal:true,isRequired:false,transformFunction:null}},ngImport:i0}) + isStandalone:true,selector:"[myDir]",inputs:{value:{classPropertyName:"value", + publicName:"value",isSignal:true,isRequired:false,transformFunction:null}}, + ngImport:i0}) diff --git a/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__impure_pipe_emits_pure_false.snap b/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__impure_pipe_emits_pure_false.snap index 615fc0065..0e5c0a836 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__impure_pipe_emits_pure_false.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__impure_pipe_emits_pure_false.snap @@ -3,4 +3,4 @@ source: crates/oxc_angular_compiler/tests/partial_pipe_test.rs expression: js --- i0.ɵɵngDeclarePipe({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",ngImport:i0,type:AsyncPipe, - name:"async",pure:false}) + isStandalone:true,name:"async",pure:false}) diff --git a/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__standalone_pure_named_pipe_omits_optional_fields.snap b/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__standalone_pure_named_pipe_omits_optional_fields.snap index c117123b9..1f15f3b1e 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__standalone_pure_named_pipe_omits_optional_fields.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/partial_pipe_test__standalone_pure_named_pipe_omits_optional_fields.snap @@ -2,5 +2,5 @@ source: crates/oxc_angular_compiler/tests/partial_pipe_test.rs expression: js --- -i0.ɵɵngDeclarePipe({minVersion:"14.0.0",version:"0.0.0-PLACEHOLDER",ngImport:i0,type:UpperPipe, +i0.ɵɵngDeclarePipe({minVersion:"14.0.0",version:"22.2.3",ngImport:i0,type:UpperPipe, name:"upper"}) diff --git a/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts new file mode 100644 index 000000000..b4f181e12 --- /dev/null +++ b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts @@ -0,0 +1,237 @@ +/** + * NgModule scopes of partially compiled libraries (#655), at runtime. + * + * A partial declaration of a component an NgModule declares carries no + * `dependencies`, and the Angular CLI's linker emits library NgModules without + * their scope, so the runtime can't find the component's directives and + * pipes. The module's file sets them with `ɵɵsetComponentScope`, and a + * standalone component lists what an imported NgModule exports. Each case is + * linked with Angular's linker and with Oxc's, and run with the real + * `@angular/core`. + */ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { linkAngularPackageSync, transformAngularFileSync } from '../index.js' + +const TMP = join(fileURLToPath(new URL('.', import.meta.url)), '.partial-ng-module-scope-tmp') +let nextCase = 0 +let previousJitMode: unknown + +beforeAll(() => { + mkdirSync(TMP, { recursive: true }) + previousJitMode = (globalThis as any).ngJitMode + ;(globalThis as any).ngJitMode = false +}) + +afterAll(() => { + ;(globalThis as any).ngJitMode = previousJitMode + rmSync(TMP, { recursive: true, force: true }) +}) + +/** Angular's linker, as the Angular CLI runs it on libraries. */ +async function angularLink(code: string, filename: string): Promise { + const require = createRequire(import.meta.url) + const babel = createRequire(require.resolve('@angular/compiler-cli/package.json'))('@babel/core') + const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel') + const plugin = createEs2015LinkerPlugin({ + linkerJitMode: false, + fileSystem: { + resolve, + exists: existsSync, + dirname, + relative, + readFile: (file: string) => readFileSync(file, 'utf8'), + } as any, + logger: { level: 1, debug() {}, info() {}, warn() {}, error() {} }, + }) + return babel.transformSync(code, { + filename, + babelrc: false, + configFile: false, + plugins: [plugin], + }).code +} + +const LINKERS = { + angular: angularLink, + oxc: async (code: string, filename: string) => linkAngularPackageSync(code, filename).code, +} + +/** + * Partially compiles `files` (`.mts` sources, importing each other as + * `./name.mjs`), links each with `linker` and loads `main`. + */ +async function load( + files: Record, + linker: keyof typeof LINKERS, +): Promise> { + const dir = join(TMP, `case-${nextCase++}`) + mkdirSync(dir, { recursive: true }) + for (const [name, source] of Object.entries(files)) { + writeFileSync(join(dir, `${name}.mts`), source) + } + for (const [name, source] of Object.entries(files)) { + const file = join(dir, `${name}.mts`) + const result = transformAngularFileSync(source, file, { + compilationMode: 'partial', + resolveImportedValues: true, + }) + expect(result.errors).toEqual([]) + // Linked as the JavaScript a library ships: Angular's linker types the + // code it writes into a `.ts` file. + const linked = await LINKERS[linker](result.code, join(dir, `${name}.mjs`)) + writeFileSync(join(dir, `${name}.mjs`), linked) + } + return import(/* @vite-ignore */ pathToFileURL(join(dir, 'main.mjs')).href) +} + +/** The class names of the directives and of the pipes `cmp`'s template can use. */ +function scope(cmp: any): { directives: string[]; pipes: string[] } { + const names = (defs: any) => + (typeof defs === 'function' ? defs() : (defs ?? [])).map((def: any) => def.type.name) + return { directives: names(cmp.ɵcmp.directiveDefs), pipes: names(cmp.ɵcmp.pipeDefs) } +} + +const DEP = ` + import { Directive, NgModule, Pipe } from '@angular/core'; + @Directive({ selector: '[d]', standalone: false }) export class D {} + @Pipe({ name: 'p', standalone: false }) export class P { transform(v) { return v; } } + @NgModule({ declarations: [D, P], exports: [D, P] }) export class DepModule {} +` + +describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', (linker) => { + it('gives a declared component the scope of its NgModule', async () => { + const m = await load( + { + dep: DEP, + main: ` + import { Component, NgModule } from '@angular/core'; + import { DepModule } from './dep.mjs'; + @Component({ selector: 'app', template: '{{ 1 | p }}', standalone: false }) + export class App {} + @NgModule({ declarations: [App], imports: [DepModule] }) export class AppModule {} + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['D', 'App'], pipes: ['P'] }) + }) + + it('gives a standalone component the exports of an imported NgModule', async () => { + const m = await load( + { + dep: DEP, + main: ` + import { Component } from '@angular/core'; + import { DepModule } from './dep.mjs'; + @Component({ selector: 'app', template: '{{ 1 | p }}', imports: [DepModule] }) + export class App {} + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['D'], pipes: ['P'] }) + }) + + it('sets the scope once the file has declared the component', async () => { + const m = await load( + { + dep: DEP, + main: ` + import { Component, NgModule, forwardRef } from '@angular/core'; + import { DepModule } from './dep.mjs'; + @NgModule({ declarations: [forwardRef(() => App)], imports: [DepModule] }) + export class AppModule {} + @Component({ selector: 'app', template: '', standalone: false }) + export class App {} + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['D', 'App'], pipes: ['P'] }) + }) + + it('lists a class an imported NgModule also exports once', async () => { + const m = await load( + { + dep: ` + import { Directive, NgModule } from '@angular/core'; + @Directive({ selector: '[s]' }) export class S {} + @NgModule({ imports: [S], exports: [S] }) export class SModule {} + `, + main: ` + import { Component } from '@angular/core'; + import { S, SModule } from './dep.mjs'; + @Component({ selector: 'app', template: '', imports: [SModule, S] }) + export class App {} + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['S'], pipes: [] }) + }) + + it('reads an exported class declared after the component when it renders', async () => { + const m = await load( + { + main: ` + import { Component, Directive, NgModule, forwardRef } from '@angular/core'; + @NgModule({ imports: [forwardRef(() => D)], exports: [forwardRef(() => D)] }) + export class M {} + @Component({ selector: 'app', template: '', imports: [M] }) + export class App {} + @Directive({ selector: '[d]' }) export class D {} + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['D'], pipes: [] }) + }) + + it('reads an exported class whose file loads the component file when it renders', async () => { + // `main` (loaded first) runs `app` before it declares `D`. + const m = await load( + { + m: ` + import { NgModule, forwardRef } from '@angular/core'; + import { D } from './main.mjs'; + @NgModule({ imports: [forwardRef(() => D)], exports: [forwardRef(() => D)] }) + export class M {} + `, + app: ` + import { Component } from '@angular/core'; + import { M } from './m.mjs'; + @Component({ selector: 'app', template: '', imports: [M] }) + export class App {} + `, + main: ` + import { Directive } from '@angular/core'; + import { M } from './m.mjs'; + import { App } from './app.mjs'; + @Directive({ selector: '[d]' }) export class D {} + export { App, M }; + `, + }, + linker, + ) + expect(scope(m.App)).toEqual({ directives: ['D'], pipes: [] }) + }) + + it('links templates with block syntax', async () => { + const m = await load( + { + main: ` + import { Component } from '@angular/core'; + @Component({ selector: 'app', template: '@if (true) { y }' }) export class App {} + `, + }, + linker, + ) + expect(m.App.ɵcmp.template).toBeTypeOf('function') + }) +})