From f05b7ad4290f95cd54a7ffb4ddddd7651e3bcc8a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 20:39:53 +0800 Subject: [PATCH 1/6] fix(partial): give NgModule components their scope (#655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A partial declaration of a component an NgModule declares had no dependencies, so after linking it had no directives or pipes. The NgModule's file now calls ɵɵsetComponentScope for each component it declares, with the module's compilation scope read across files (source @NgModule decorators and .d.ts ɵmod types). A standalone component that imports an NgModule now lists the module's exported directives and pipes. Partial declarations also carry a real version (the target Angular version, else 22.2.3) instead of 0.0.0-PLACEHOLDER, which Angular's published linker reads as 0.0.0 and so turns off block syntax. Co-Authored-By: Claude Opus 5.5 --- .../src/component/cross_file_elision.rs | 5 + .../cross_file_elision/ng_module_scope.rs | 918 ++++++++++++++++++ .../src/component/metadata.rs | 35 + .../src/component/transform.rs | 168 +++- .../src/directive/definition.rs | 12 +- .../src/injectable/definition.rs | 75 +- .../src/ng_module/definition.rs | 62 +- .../src/partial/class_metadata.rs | 17 +- .../src/partial/component.rs | 58 +- .../src/partial/directive.rs | 13 +- .../src/partial/factory.rs | 9 +- .../src/partial/injectable.rs | 12 +- .../src/partial/injector.rs | 9 +- .../oxc_angular_compiler/src/partial/mod.rs | 27 +- .../src/partial/ng_module.rs | 12 +- .../oxc_angular_compiler/src/partial/pipe.rs | 12 +- .../src/partial/service.rs | 7 +- .../src/pipe/definition.rs | 12 +- .../src/service/definition.rs | 17 +- .../tests/defer_implicit_imports_test.rs | 2 +- .../tests/ngtsc_emit_parity_test.rs | 2 +- .../partial_class_metadata_async_test.rs | 57 +- .../tests/partial_directive_test.rs | 38 +- .../tests/partial_e2e_test.rs | 6 +- .../tests/partial_factory_test.rs | 21 +- .../tests/partial_injectable_test.rs | 44 +- .../tests/partial_ng_module_scope_test.rs | 361 +++++++ .../tests/partial_ng_module_test.rs | 21 +- .../tests/partial_pipe_test.rs | 13 +- .../tests/partial_upstream_parity_test.rs | 8 +- .../partial-ng-module-scope-runtime.test.ts | 153 +++ 31 files changed, 2046 insertions(+), 160 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs create mode 100644 crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs create mode 100644 napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts 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..986f11f3d --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs @@ -0,0 +1,918 @@ +//! 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 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, +} + +/// 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>>>, +} + +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)?; + components + .iter() + .map(|class| { + Some(ComponentScope { + component: inner.imported_name(class, &file)?, + entries: entries.clone(), + }) + }) + .collect() + } + + /// The directives and pipes the NgModule `file` imports as `namespace.name` + /// (or `name`) exports, or `None` when it's no NgModule or its scope + /// can't be read statically. + pub(crate) fn ng_module_exports( + &self, + file: &Path, + source: &str, + namespace: Option<&str>, + name: &str, + ) -> Option> { + let file = self.inner.set_compiled_source(file, source); + 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)?; + let Declared::NgModule { exports, .. } = &*declared else { return None }; + let scope = self.inner.exported_scope(&class, exports)?; + self.inner.named_entries(scope.to_vec(), &file) + } +} + +/// `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, + } +} + +/// 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 { + // The resolver gives real paths; `/tmp` is a link on macOS. + let key = std::fs::canonicalize(file) + .unwrap_or_else(|_| file.to_path_buf()) + .to_string_lossy() + .into_owned(); + 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 path = resolved.path(); + let file = path.to_string_lossy().into_owned(); + // A package is no file the build watches. + if !path.components().any(|part| part.as_os_str() == "node_modules") { + self.value_dependencies.borrow_mut().insert(file.clone()); + } + Some(file) + } + + /// 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, depth: u16) -> Option { + if depth > MAX_EXPORT_CHAIN { + return None; + } + let exports = self.file_exports(file)?; + match exports.named.get(name) { + Some(Export::Local(local)) => self.find_local(file, local, depth + 1), + Some(Export::From { spec, name }) => { + self.find_export(&self.resolve_types(file, spec)?, name, depth + 1) + } + None if name == "default" => None, + None => exports.stars.iter().find_map(|spec| { + self.find_export(&self.resolve_types(file, spec)?, name, depth + 1) + }), + } + } + + /// The declaration the top-level binding `local` of `file` is. + fn find_local(&self, file: &str, local: &str, depth: u16) -> Option { + match self.file_exports(file)?.locals.get(local)? { + Local::Declared => Some(Decl { file: file.to_string(), name: local.to_string() }), + Local::Import { spec, imported: Some(imported) } => { + self.find_export(&self.resolve_types(file, spec)?, imported, depth + 1) + } + Local::Import { imported: None, .. } => None, + } + } + + /// 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, depth)?; + 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 { + out.extend(self.resolve_refs(items, class.owner.as_deref(), depth + 1)?); + } 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 }) + }) + .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 entry = self.resolve_types(file, specifier)?; + let name = self.export_name(&entry, &class.decl)?; + return Some(ImportedName::Module { specifier: specifier.clone(), name }); + } + 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 mut specifier = self.make_relative_path(from_dir, Path::new(&class.decl.file))?; + if let Some(stripped) = specifier.strip_suffix(".d") { + specifier.truncate(stripped.len()); + } + 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, 0) 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()); + 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 }; + 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.exported.is_none() && !decl.export_kind.is_type() => + { + if let Some(source) = decl.source.value.as_str() { + 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..5c5d06458 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -1071,6 +1071,41 @@ impl<'a> ComponentMetadata<'a> { } } +// NgModule scopes read across files for partial compilation (#655), by +// `CrossFileAnalyzer::ng_module_component_scopes` and `ng_module_exports`. + +/// 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, +} + +/// 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..d918604cb 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, ), }; @@ -3566,6 +3569,25 @@ 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 |import: &ImportRef<'_>| { + analyzer.ng_module_exports( + std::path::Path::new(path), + source, + import.namespace.as_ref().map(Ident::as_str), + import.name.as_str(), + ) + } + }); // 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 +3736,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 +3942,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 +3978,7 @@ pub fn transform_angular_file( resolved_resources, import_selector, can_defer, + ng_module_exports, false, ) { Ok(mut compilation_result) => { @@ -4059,6 +4088,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 +4221,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 +4444,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 +4544,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 +4577,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 +4661,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 +4696,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 +4748,34 @@ 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 { + if !external_decls.is_empty() { + external_decls.push('\n'); + } + external_decls.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 +4841,7 @@ pub fn transform_angular_file( &service_metadata, type_argument_count, options.compilation_mode, + options.angular_version, ); let emitter = JsEmitter::new(); @@ -4852,6 +4921,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(); @@ -5294,6 +5364,78 @@ 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()?)) + } + }; + dependencies.push(match namespace { + Some(namespace) => dependency.with_type_namespace(namespace), + None => dependency, + }); + } + Some(dependencies) +} + +/// `i0.ɵɵsetComponentScope(Cmp, [directives], [pipes]);` for `scope`, with +/// the modules its classes come from imported as namespaces. +#[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 +5753,7 @@ pub fn compile_component_hmr_update( Some(resources), None, None, + None, true, ) { Ok(compiled) => compiled, @@ -5674,6 +5817,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 +5828,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 +5964,11 @@ fn html_parse_error_diagnostic( /// refers to (see [`ImportedClass`]). type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; +/// The directives and pipes the NgModule an `imports` entry refers to +/// exports, when cross-file analysis reads them. +type NgModuleExports<'f> = + &'f dyn Fn(&ImportRef<'_>) -> Option>; + /// What `@defer` reads of the standalone component or directive an `imports` /// entry refers to. struct ImportedClass { @@ -6147,6 +6301,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 +6423,15 @@ fn compile_component_full<'a>( ) }) .collect(); + let import_scopes: std::vec::Vec<_> = ng_module_exports + .into_iter() + .flat_map(|exports| metadata.import_refs.iter().map(move |import| (import, exports))) + .filter_map(|(import, exports)| { + let entries = exports(import)?; + 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 +6440,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..fffaeafa9 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 }, @@ -268,9 +273,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 +391,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 +413,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 +737,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 +765,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 +1054,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..76e6f4ae1 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,13 +75,14 @@ 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 @@ -206,6 +208,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 +219,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 +700,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..91fd68684 100644 --- a/crates/oxc_angular_compiler/src/partial/mod.rs +++ b/crates/oxc_angular_compiler/src/partial/mod.rs @@ -125,14 +125,31 @@ 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(), + } +} + /// 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..b89bc81c4 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), @@ -118,6 +119,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 +131,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 +191,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 +201,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..35c42ab3f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -0,0 +1,361 @@ +//! 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); + // After the module's declarations: it names the component. + let module = code.find("ɵɵngDeclareNgModule").unwrap(); + assert!(code.find("ɵɵsetComponentScope").unwrap() > module, "{}", 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); +} 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..fd0cae362 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::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", PLACEHOLDER_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(); 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/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..982e8afcb --- /dev/null +++ b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts @@ -0,0 +1,153 @@ +/** + * 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('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') + }) +}) From 97c4ec25d9e25231445dde316d1eceace8825c96 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 21:13:22 +0800 Subject: [PATCH 2/6] fix(partial): run NgModule scopes after the file, list each class once - Set component scopes at the end of the module's file with `() => [...]` lists, and skip a component whose file imports the module's file back. - A standalone component lists a class an imported NgModule exports once, by declaration, not by name. - Look up each `(file, name)` once per export search, so `export *` cycles end in linear time. - Import a workspace library class by path when its entry point doesn't export it. - Emit `isStandalone: true` for a linker before v19, which reads a missing field as `false`. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 173 ++++++++++++---- .../src/component/metadata.rs | 3 +- .../src/component/transform.rs | 52 +++-- .../src/partial/component.rs | 5 +- .../src/partial/directive.rs | 5 +- .../oxc_angular_compiler/src/partial/mod.rs | 11 + .../oxc_angular_compiler/src/partial/pipe.rs | 5 +- .../tests/partial_ng_module_scope_test.rs | 191 +++++++++++++++++- .../tests/partial_pipe_test.rs | 19 +- ...legacy_inputs_string_when_names_match.snap | 2 +- ..._directive_emits_required_fields_only.snap | 2 +- ..._new_format_and_bumps_minversion_17_1.snap | 5 +- ...pe_test__impure_pipe_emits_pure_false.snap | 2 +- ...pure_named_pipe_omits_optional_fields.snap | 2 +- .../partial-ng-module-scope-runtime.test.ts | 38 ++++ 15 files changed, 429 insertions(+), 86 deletions(-) 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 index 986f11f3d..c17502280 100644 --- 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 @@ -126,6 +126,10 @@ pub(super) struct ScopeState { 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 { @@ -160,7 +164,11 @@ impl CrossFileAnalyzer { 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, .. }) { + // The call reads the component class when this file runs, which + // in an import cycle back to it can be before the class exists. + if matches!(kind, ScopeKind::Directive { is_component: true, .. }) + && (class.decl.file == file || !inner.imports_file(&class.decl.file, &file)) + { components.push(class.clone()); } scope.push(ScopeItem { class, kind }); @@ -177,28 +185,50 @@ impl CrossFileAnalyzer { .collect() } - /// The directives and pipes the NgModule `file` imports as `namespace.name` - /// (or `name`) exports, or `None` when it's no NgModule or its scope - /// can't be read statically. - pub(crate) fn ng_module_exports( + /// For each of a standalone component's `imports` (`namespace.name` or + /// `name` in `file`), 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, - namespace: Option<&str>, - name: &str, - ) -> Option> { + imports: &[(Option<&str>, &str)], + ) -> Vec>> { let file = self.inner.set_compiled_source(file, source); - 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)?; - let Declared::NgModule { exports, .. } = &*declared else { return None }; - let scope = self.inner.exported_scope(&class, exports)?; - self.inner.named_entries(scope.to_vec(), &file) + 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 entries = self.inner.named_entries(added.clone(), &file)?; + listed.extend(added.into_iter().map(|item| item.class.decl)); + Some(entries) + }) + .collect() } } @@ -219,6 +249,11 @@ fn scope_kind(declared: &Declared) -> Option { } } +/// 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() @@ -258,15 +293,38 @@ impl Inner { self.resolver.clone_with_options(options) }); let resolved = resolver.resolve_dts(from_file, spec).ok()?; - let path = resolved.path(); - let file = path.to_string_lossy().into_owned(); + let file = resolved.path().to_string_lossy().into_owned(); // A package is no file the build watches. - if !path.components().any(|part| part.as_os_str() == "node_modules") { + if !in_node_modules(&file) { self.value_dependencies.borrow_mut().insert(file.clone()); } Some(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 file == target { + return true; + } + if in_node_modules(&file) || !seen.insert(file.clone()) { + continue; + } + let Some(exports) = self.file_exports(&file) else { continue }; + let re_exports = exports.named.values().filter_map(|export| match export { + Export::From { spec, .. } => Some(spec), + Export::Local(_) => None, + }); + for spec in exports.import_specs.iter().chain(&exports.stars).chain(re_exports) { + 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) { @@ -279,34 +337,48 @@ impl Inner { } /// The declaration `file` exports as `name`, following re-exports. - fn find_export(&self, file: &str, name: &str, depth: u16) -> Option { - if depth > MAX_EXPORT_CHAIN { + 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)) => self.find_local(file, local, depth + 1), + 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.find_export(&self.resolve_types(file, spec)?, name, depth + 1) + 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.find_export(&self.resolve_types(file, spec)?, name, depth + 1) + self.search_export(&self.resolve_types(file, spec)?, name, depth + 1, seen) }), } } - /// The declaration the top-level binding `local` of `file` is. - fn find_local(&self, file: &str, local: &str, depth: u16) -> Option { - match self.file_exports(file)?.locals.get(local)? { - Local::Declared => Some(Decl { file: file.to_string(), name: local.to_string() }), - Local::Import { spec, imported: Some(imported) } => { - self.find_export(&self.resolve_types(file, spec)?, imported, depth + 1) - } - Local::Import { imported: None, .. } => None, - } - } - /// 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 { @@ -324,7 +396,7 @@ impl Inner { (None, Some(_)) => raw.name.as_str(), _ => return None, }; - let decl = self.find_export(&self.resolve_types(&raw.file, spec)?, name, depth)?; + 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) @@ -405,7 +477,12 @@ impl Inner { let class = self.resolve_ref(raw, owner, 0)?; let declared = self.declared(&class.decl)?; if let Declared::List(items) = &*declared { - out.extend(self.resolve_refs(items, class.owner.as_deref(), depth + 1)?); + 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)); } @@ -460,9 +537,17 @@ impl Inner { .then(|| ImportedName::Local(class.decl.name.clone())); } if let Some(specifier) = &class.owner { - let entry = self.resolve_types(file, specifier)?; - let name = self.export_name(&entry, &class.decl)?; - return Some(ImportedName::Module { specifier: specifier.clone(), name }); + 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 @@ -498,7 +583,7 @@ impl Inner { 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, 0) else { continue }; + let Some(found) = self.find_export(file, &name) else { continue }; if found.name == name || !names.contains_key(&found) { names.insert(found, name); } diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 5c5d06458..4378cae45 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -1072,7 +1072,8 @@ impl<'a> ComponentMetadata<'a> { } // NgModule scopes read across files for partial compilation (#655), by -// `CrossFileAnalyzer::ng_module_component_scopes` and `ng_module_exports`. +// `CrossFileAnalyzer::ng_module_component_scopes` and +// `standalone_import_scopes`. /// A directive or pipe in a scope. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index d918604cb..7514bdc1e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3310,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(); @@ -3579,13 +3583,14 @@ pub fn transform_angular_file( && matches!(options.compilation_mode, crate::CompilationMode::Partial) }) .map(|analyzer| { - move |import: &ImportRef<'_>| { - analyzer.ng_module_exports( - std::path::Path::new(path), - source, - import.namespace.as_ref().map(Ident::as_str), - import.name.as_str(), - ) + move |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, &imports) } }); // Without cross-file analysis, only the classes declared in this file @@ -4765,10 +4770,8 @@ pub fn transform_angular_file( ) { for scope in &scopes { - if !external_decls.is_empty() { - external_decls.push('\n'); - } - external_decls.push_str(&set_component_scope_statement( + decls_at_end.push('\n'); + decls_at_end.push_str(&set_component_scope_statement( allocator, scope, &mut file_namespace_registry, @@ -5161,6 +5164,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 @@ -5402,8 +5408,10 @@ fn scope_dependencies<'a>( Some(dependencies) } -/// `i0.ɵɵsetComponentScope(Cmp, [directives], [pipes]);` for `scope`, with -/// the modules its classes come from imported as namespaces. +/// `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, @@ -5429,7 +5437,7 @@ fn set_component_scope_statement<'a>( list.push(name_of(&entry.name)); } format!( - "{}.ɵɵsetComponentScope({component}, [{}], [{}]);", + "{}.ɵɵsetComponentScope({component}, () => [{}], () => [{}]);", registry.angular_core_ns(), directives.join(", "), pipes.join(", ") @@ -5964,10 +5972,12 @@ fn html_parse_error_diagnostic( /// refers to (see [`ImportedClass`]). type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; -/// The directives and pipes the NgModule an `imports` entry refers to -/// exports, when cross-file analysis reads them. +/// 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(&ImportRef<'_>) -> Option>; + &'f dyn Fn( + &[ImportRef<'_>], + ) -> std::vec::Vec>>; /// What `@defer` reads of the standalone component or directive an `imports` /// entry refers to. @@ -6424,10 +6434,12 @@ fn compile_component_full<'a>( }) .collect(); let import_scopes: std::vec::Vec<_> = ng_module_exports + .map(|exports| exports(&metadata.import_refs)) + .unwrap_or_default() .into_iter() - .flat_map(|exports| metadata.import_refs.iter().map(move |import| (import, exports))) - .filter_map(|(import, exports)| { - let entries = exports(import)?; + .zip(&metadata.import_refs) + .filter_map(|(entries, import)| { + let entries = entries?; let dependencies = scope_dependencies(allocator, &entries, namespace_registry)?; Some((import.key, dependencies)) }) diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index fffaeafa9..9de7f1232 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -102,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, )); } diff --git a/crates/oxc_angular_compiler/src/partial/directive.rs b/crates/oxc_angular_compiler/src/partial/directive.rs index 76e6f4ae1..14c701f69 100644 --- a/crates/oxc_angular_compiler/src/partial/directive.rs +++ b/crates/oxc_angular_compiler/src/partial/directive.rs @@ -87,10 +87,11 @@ pub(crate) fn create_directive_definition_map<'a>( // 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, )); } diff --git a/crates/oxc_angular_compiler/src/partial/mod.rs b/crates/oxc_angular_compiler/src/partial/mod.rs index 91fd68684..b4c62bc1e 100644 --- a/crates/oxc_angular_compiler/src/partial/mod.rs +++ b/crates/oxc_angular_compiler/src/partial/mod.rs @@ -150,6 +150,17 @@ pub fn declaration_version(angular_version: Option) -> St } } +/// 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/pipe.rs b/crates/oxc_angular_compiler/src/partial/pipe.rs index b89bc81c4..3a95d2517 100644 --- a/crates/oxc_angular_compiler/src/partial/pipe.rs +++ b/crates/oxc_angular_compiler/src/partial/pipe.rs @@ -59,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, )); } 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 index 35c42ab3f..53d0eb52d 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -123,10 +123,13 @@ export class AppModule {} 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); - // After the module's declarations: it names the component. - let module = code.find("ɵɵngDeclareNgModule").unwrap(); - assert!(code.find("ɵɵsetComponentScope").unwrap() > module, "{}", 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")), @@ -190,7 +193,9 @@ export class AppModule {} ); let code = compact(&result.code); assert!( - code.contains("i0.ɵɵsetComponentScope(App,[i1.UiDir,i1.InnerDir,App],[i1.UiPipe]);"), + code.contains( + "i0.ɵɵsetComponentScope(App,()=>[i1.UiDir,i1.InnerDir,App],()=>[i1.UiPipe]);" + ), "{}", result.code ); @@ -224,8 +229,16 @@ 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("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); } @@ -358,4 +371,168 @@ export class App {} 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 + ); } diff --git a/crates/oxc_angular_compiler/tests/partial_pipe_test.rs b/crates/oxc_angular_compiler/tests/partial_pipe_test.rs index fd0cae362..a9b22b56d 100644 --- a/crates/oxc_angular_compiler/tests/partial_pipe_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_pipe_test.rs @@ -1,7 +1,7 @@ //! Tests for the partial-declaration Pipe emitter (`ɵɵngDeclarePipe`). use oxc_allocator::{Allocator, Box}; -use oxc_angular_compiler::partial::PLACEHOLDER_VERSION; +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}, @@ -33,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", PLACEHOLDER_VERSION, &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}"); @@ -209,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/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 index 982e8afcb..b6f376298 100644 --- a/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts +++ b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts @@ -138,6 +138,44 @@ describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', 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('links templates with block syntax', async () => { const m = await load( { From dbda06f3aeb34ab02f4d51c471f54ec140cfcfad Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 21:26:06 +0800 Subject: [PATCH 3/6] fix(partial): find import cycles through barrels and namespace re-exports - Skip a component when the file its scope call imports it from loads the module file back, not only the file that declares it. - Follow `export * as ns from`, and not type-only imports and re-exports, when looking for a cycle. - List a module's classes as seen only once its list is kept: a module with a pipe of no static name no longer hides them from later ones. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 78 +++++++---- .../tests/partial_ng_module_scope_test.rs | 122 ++++++++++++++++++ 2 files changed, 177 insertions(+), 23 deletions(-) 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 index c17502280..d938597e3 100644 --- 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 @@ -109,6 +109,9 @@ struct FileExports { 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. @@ -164,25 +167,32 @@ impl CrossFileAnalyzer { let mut components = Vec::new(); for (class, declared) in inner.resolve_refs(declarations, None, 0)? { let kind = scope_kind(&declared)?; - // The call reads the component class when this file runs, which - // in an import cycle back to it can be before the class exists. - if matches!(kind, ScopeKind::Directive { is_component: true, .. }) - && (class.decl.file == file || !inner.imports_file(&class.decl.file, &file)) - { + 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)?; - components - .iter() - .map(|class| { - Some(ComponentScope { - component: inner.imported_name(class, &file)?, - entries: entries.clone(), - }) - }) - .collect() + 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: in an + // import cycle back to it, through the file it's imported from + // or the one that declares it, that can be before it exists. + let loaded = match &component { + ImportedName::Local(_) => None, + ImportedName::Module { specifier, .. } => inner.resolve_types(&file, specifier), + }; + if loaded + .iter() + .chain([&class.decl.file]) + .any(|from| *from != file && inner.imports_file(from, &file)) + { + continue; + } + scopes.push(ComponentScope { component, entries: entries.clone() }); + } + Some(scopes) } /// For each of a standalone component's `imports` (`namespace.name` or @@ -225,6 +235,11 @@ impl CrossFileAnalyzer { .cloned() .collect(); let entries = self.inner.named_entries(added.clone(), &file)?; + // 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) }) @@ -314,11 +329,7 @@ impl Inner { continue; } let Some(exports) = self.file_exports(&file) else { continue }; - let re_exports = exports.named.values().filter_map(|export| match export { - Export::From { spec, .. } => Some(spec), - Export::Local(_) => None, - }); - for spec in exports.import_specs.iter().chain(&exports.stars).chain(re_exports) { + for spec in &exports.loads { pending.extend(self.resolve_types(&file, spec)); } } @@ -661,6 +672,19 @@ fn collect_file_exports(program: &Program<'_>) -> FileExports { 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) => { @@ -691,6 +715,11 @@ fn collect_file_exports(program: &Program<'_>) -> FileExports { } 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(), @@ -699,11 +728,14 @@ fn collect_file_exports(program: &Program<'_>) -> FileExports { export(&mut exports, &spec.exported.name(), how); } } - Statement::ExportAllDeclaration(decl) - if decl.exported.is_none() && !decl.export_kind.is_type() => - { + Statement::ExportAllDeclaration(decl) if !decl.export_kind.is_type() => { if let Some(source) = decl.source.value.as_str() { - exports.stars.push(source.to_string()); + 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 { 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 index 53d0eb52d..ecd5a8da6 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -536,3 +536,125 @@ import { LibModule } from '@ws/lib'; 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); +} From fae6557970e6f818c7ab609b568e402ff35c406f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 21:35:03 +0800 Subject: [PATCH 4/6] fix(partial): read a module's later-declared exports through forwardRef A class an imported NgModule exports that the file declares after the standalone component is listed as `forwardRef(() => X)`, as ngtsc does, so creating the component's definition doesn't read it too early. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 50 +++++++++++++++++-- .../src/component/metadata.rs | 3 ++ .../src/component/transform.rs | 19 +++++-- .../tests/partial_ng_module_scope_test.rs | 27 ++++++++++ .../partial-ng-module-scope-runtime.test.ts | 17 +++++++ 5 files changed, 108 insertions(+), 8 deletions(-) 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 index d938597e3..5cdea8c48 100644 --- 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 @@ -21,6 +21,7 @@ use oxc_ast::ast::{ 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}; @@ -195,8 +196,8 @@ impl CrossFileAnalyzer { Some(scopes) } - /// For each of a standalone component's `imports` (`namespace.name` or - /// `name` in `file`), the directives and pipes it adds when it's an + /// 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. @@ -204,9 +205,41 @@ impl CrossFileAnalyzer { &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)| { @@ -234,7 +267,12 @@ impl CrossFileAnalyzer { .filter(|item| !listed.contains(&item.class.decl)) .cloned() .collect(); - let entries = self.inner.named_entries(added.clone(), &file)?; + let mut entries = self.inner.named_entries(added.clone(), &file)?; + for entry in &mut entries { + entry.is_forward_reference = matches!( + &entry.name, ImportedName::Local(name) if declared_later.contains(name) + ); + } // A pipe the linker can't match without a name. if entries.iter().any(|entry| matches!(entry.kind, ScopeKind::Pipe { name: None })) { @@ -533,7 +571,11 @@ impl Inner { items .into_iter() .map(|item| { - Some(ScopeEntry { name: self.imported_name(&item.class, file)?, kind: item.kind }) + Some(ScopeEntry { + name: self.imported_name(&item.class, file)?, + kind: item.kind, + is_forward_reference: false, + }) }) .collect() } diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 4378cae45..6f5d36000 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -1098,6 +1098,9 @@ pub enum ImportedName { 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. diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 7514bdc1e..522ff09e2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3583,14 +3583,19 @@ pub fn transform_angular_file( && matches!(options.compilation_mode, crate::CompilationMode::Partial) }) .map(|analyzer| { - move |imports: &[ImportRef<'_>]| { + 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, &imports) + analyzer.standalone_import_scopes( + std::path::Path::new(path), + source, + component, + &imports, + ) } }); // Without cross-file analysis, only the classes declared in this file @@ -5400,9 +5405,14 @@ fn scope_dependencies<'a>( TemplateDependency::pipe(allocator, name, ident(pipe_name.as_deref()?)) } }; - dependencies.push(match namespace { + 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) @@ -5976,6 +5986,7 @@ type ImportSelector<'f> = &'f dyn Fn(&ImportRef<'_>) -> Option; /// to adds, when cross-file analysis reads them. type NgModuleExports<'f> = &'f dyn Fn( + &str, &[ImportRef<'_>], ) -> std::vec::Vec>>; @@ -6434,7 +6445,7 @@ fn compile_component_full<'a>( }) .collect(); let import_scopes: std::vec::Vec<_> = ng_module_exports - .map(|exports| exports(&metadata.import_refs)) + .map(|exports| exports(metadata.class_name.as_str(), &metadata.import_refs)) .unwrap_or_default() .into_iter() .zip(&metadata.import_refs) 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 index ecd5a8da6..c6ed0bbb1 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -658,3 +658,30 @@ export class SharedModule {} ); 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 + ); +} 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 index b6f376298..7541321cc 100644 --- a/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts +++ b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts @@ -176,6 +176,23 @@ describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', 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('links templates with block syntax', async () => { const m = await load( { From 1d47680ba9e4e8c3c97a7df79e23de55ce7e26c6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 21:46:01 +0800 Subject: [PATCH 5/6] fix(partial): match the compiled file's path to the resolver's on Windows `canonicalize` gives the compiled file a `\\?\` path, which the resolver's paths don't have, so an import cycle back to it was never found. Co-Authored-By: Claude Opus 5.5 --- .../component/cross_file_elision/ng_module_scope.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 5cdea8c48..e5845938f 100644 --- 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 @@ -316,11 +316,19 @@ 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 { - // The resolver gives real paths; `/tmp` is a link on macOS. + // The resolver gives real paths; `/tmp` is a link on macOS. On + // Windows, it drops the `\\?\` prefix `canonicalize` adds. let key = std::fs::canonicalize(file) .unwrap_or_else(|_| file.to_path_buf()) .to_string_lossy() .into_owned(); + let key = if let Some(share) = key.strip_prefix(r"\\?\UNC\") { + format!(r"\\{share}") + } else if let Some(path) = key.strip_prefix(r"\\?\") { + path.to_string() + } else { + key + }; let current = self .parsed .borrow() From ae05c6f9caee61e26fd51af8da1a9dd85a55c70c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 30 Oct 2026 21:55:39 +0800 Subject: [PATCH 6/6] fix(partial): read imported scope classes in a cycle through forwardRef - A class an imported NgModule exports, whose file loads the standalone component's file back, is listed as `forwardRef(() => i1.X)`: the component's definition can be created before that file has run. - Name a class imported by path from an `.mts`/`.cts` file by the `.mjs` /`.cjs` file TypeScript writes for it. - Compare canonical paths when looking for a cycle: a path through a tsconfig alias can be spelled differently, as Windows short names are. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 94 ++++++++++++------- .../tests/partial_ng_module_scope_test.rs | 80 ++++++++++++++++ .../partial-ng-module-scope-runtime.test.ts | 29 ++++++ 3 files changed, 167 insertions(+), 36 deletions(-) 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 index e5845938f..26dd6a0c6 100644 --- 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 @@ -177,18 +177,8 @@ impl CrossFileAnalyzer { 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: in an - // import cycle back to it, through the file it's imported from - // or the one that declares it, that can be before it exists. - let loaded = match &component { - ImportedName::Local(_) => None, - ImportedName::Module { specifier, .. } => inner.resolve_types(&file, specifier), - }; - if loaded - .iter() - .chain([&class.decl.file]) - .any(|from| *from != file && inner.imports_file(from, &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() }); @@ -268,10 +258,14 @@ impl CrossFileAnalyzer { .cloned() .collect(); let mut entries = self.inner.named_entries(added.clone(), &file)?; - for entry in &mut entries { - entry.is_forward_reference = matches!( - &entry.name, ImportedName::Local(name) if declared_later.contains(name) - ); + // 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 })) @@ -302,6 +296,22 @@ fn scope_kind(declared: &Declared) -> Option { } } +/// `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") @@ -316,19 +326,7 @@ 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 { - // The resolver gives real paths; `/tmp` is a link on macOS. On - // Windows, it drops the `\\?\` prefix `canonicalize` adds. - let key = std::fs::canonicalize(file) - .unwrap_or_else(|_| file.to_path_buf()) - .to_string_lossy() - .into_owned(); - let key = if let Some(share) = key.strip_prefix(r"\\?\UNC\") { - format!(r"\\{share}") - } else if let Some(path) = key.strip_prefix(r"\\?\") { - path.to_string() - } else { - key - }; + let key = real_path(file); let current = self .parsed .borrow() @@ -362,18 +360,33 @@ impl Inner { 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 file == target { - return true; - } 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)); @@ -626,10 +639,19 @@ impl Inner { return Some(ImportedName::Module { specifier, name }); } let from_dir = Path::new(file).parent()?; - let mut specifier = self.make_relative_path(from_dir, Path::new(&class.decl.file))?; - if let Some(stripped) = specifier.strip_suffix(".d") { - specifier.truncate(stripped.len()); - } + 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 }) } 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 index c6ed0bbb1..521f24b92 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_scope_test.rs @@ -685,3 +685,83 @@ export class App {} 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/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts index 7541321cc..b4f181e12 100644 --- a/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts +++ b/napi/angular-compiler/test/partial-ng-module-scope-runtime.test.ts @@ -193,6 +193,35 @@ describe.each(Object.keys(LINKERS) as (keyof typeof LINKERS)[])('linked by %s', 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( {