Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 203 additions & 2 deletions crates/oxc_angular_compiler/src/component/cross_file_elision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ use std::rc::Rc;

use oxc_allocator::Allocator;
use oxc_ast::ast::{
BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Statement,
TSNamespaceDeclarationBody,
BindingPattern, Class, Declaration, ExportDefaultDeclarationKind, Expression,
ObjectPropertyKind, Statement, TSNamespaceDeclarationBody,
};
use oxc_parser::Parser;
use oxc_resolver::{
Expand Down Expand Up @@ -80,6 +80,10 @@ struct Inner {
/// Memoized per-export value evaluations: `(file, local_name)` -> value.
/// `None` means evaluated-but-opaque.
value_memo: RefCell<FxHashMap<(String, String), Option<crate::directive::StaticValue>>>,
/// Memoized standalone class selectors: `(file, exported_name, local_name,
/// implicit_standalone)` -> selector (see
/// [`Self::standalone_directive_selector`]).
selector_memo: RefCell<FxHashMap<(String, String, String, bool), Option<String>>>,
/// Exports whose evaluation is in progress (circular `const` chains —
/// `a.A = b.B; b.B = a.A` — resolve the cycle edge to opaque, like
/// `analyzing` does for files).
Expand Down Expand Up @@ -115,6 +119,80 @@ impl ImportValueResolver for CrossFileResolver {
}
}

/// The selector of the class `local` declares in `source` (the text of
/// `file_path`; `"default"` for `export default class`) and exports as
/// `exported`, when it is a standalone `@Component` or `@Directive` whose
/// `selector` and `standalone` are literals.
fn standalone_class_selector(
file_path: &str,
source: &str,
exported: &str,
local: &str,
implicit_standalone: bool,
) -> Option<String> {
let allocator = Allocator::default();
let source_type = SourceType::from_path(file_path).unwrap_or_default();
let parser_ret = Parser::new(&allocator, source, source_type).parse();
let program = &parser_ret.program;

// A plain `class Local` must be exported as `exported` by a local
// `export { Local as exported }`: the export map also records namespace
// members, which can share the name of a top-level class the file doesn't
// export (or exports under another name).
let exported_locally = program.body.iter().any(|stmt| match stmt {
Statement::ExportNamedDeclaration(decl) if !decl.export_kind.is_type() => {
decl.specifiers.iter().any(|spec| {
!spec.export_kind.is_type()
&& spec.local.name() == local
&& spec.exported.name() == exported
})
}
_ => false,
});
let is_local = |class: &Class| class.id.as_ref().is_some_and(|id| id.name == local);
let class = program.body.iter().find_map(|stmt| match stmt {
Statement::ClassDeclaration(class) if exported_locally && is_local(class) => Some(&**class),
Statement::ExportDeclaration(decl) => match &decl.declaration {
Declaration::ClassDeclaration(class) if is_local(class) => Some(&**class),
_ => None,
},
Statement::ExportDefaultDeclaration(decl) if local == "default" => {
match &decl.declaration {
ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(&**class),
_ => None,
}
}
_ => None,
})?;

let consts = crate::directive::collect_string_consts(&allocator, program);
let decorator = ["Component", "Directive"].iter().find_map(|name| {
crate::directive::find_angular_class_decorator(&class.decorators, name, &consts)
})?;
let Expression::CallExpression(call) = &decorator.expression else { return None };
let Some(Expression::ObjectExpression(object)) =
call.arguments.first().and_then(|arg| arg.as_expression())
else {
return None;
};

let mut selector = None;
let mut standalone = implicit_standalone;
for property in &object.properties {
let ObjectPropertyKind::ObjectProperty(property) = property else { return None };
// A computed key can override `selector` or `standalone`.
let key = property.key.static_name()?;
if key == "selector" {
let Expression::StringLiteral(value) = &property.value else { return None };
selector = Some(value.value.as_str()?.to_string());
} else if key == "standalone" {
let Expression::BooleanLiteral(value) = &property.value else { return None };
standalone = value.value;
}
}
selector.filter(|selector| standalone && !selector.trim().is_empty())
}

impl CrossFileAnalyzer {
/// Create a new cross-file analyzer.
///
Expand Down Expand Up @@ -143,6 +221,7 @@ impl CrossFileAnalyzer {
analyzing: RefCell::new(FxHashSet::default()),
sources: RefCell::new(FxHashMap::default()),
value_memo: RefCell::new(FxHashMap::default()),
selector_memo: RefCell::new(FxHashMap::default()),
evaluating_values: RefCell::new(FxHashSet::default()),
value_dependencies: RefCell::new(FxHashSet::default()),
values_enabled,
Expand Down Expand Up @@ -194,6 +273,31 @@ impl CrossFileAnalyzer {
self.inner.resolve_export_value(import_source, import_name, from_file)
}

/// The selector of the standalone `@Component` or `@Directive` class that
/// `import_name` is exported as from `import_source`, as `import_source`
/// is written in `from_file`, following named re-exports. Used to tell
/// whether a template matches the class, for `@defer` (#313).
///
/// `None` when the import can't be followed to a class declared in a
/// source file (packages, `export *`, unresolvable paths), the class isn't
/// a standalone component or directive, or its `selector` or `standalone`
/// isn't a literal: the caller keeps the import eager. Files read are
/// recorded in [`Self::take_value_dependencies`].
pub(crate) fn standalone_directive_selector(
&self,
import_source: &str,
import_name: &str,
from_file: &Path,
implicit_standalone: bool,
) -> Option<String> {
self.inner.standalone_directive_selector(
import_source,
import_name,
from_file,
implicit_standalone,
)
}

/// The resolver the decorator evaluator reads `from_file`'s imported
/// bindings through (see `TransformOptions::resolve_imported_values`).
pub(crate) fn value_resolver(&self, from_file: PathBuf) -> CrossFileResolver {
Expand Down Expand Up @@ -348,6 +452,77 @@ impl Inner {
self.export_value_at(&resolved, import_name, 0)
}

fn standalone_directive_selector(
&self,
import_source: &str,
import_name: &str,
from_file: &Path,
implicit_standalone: bool,
) -> Option<String> {
let mut file = self.resolve_module_spec(from_file.parent()?, import_source)?;
let mut name = import_name.to_string();
for _ in 0..MAX_EXPORT_CHAIN {
if Path::new(&file).components().any(|c| c.as_os_str() == "node_modules") {
return None;
}
self.value_dependencies.borrow_mut().insert(file.clone());
if !self.cache.borrow().contains_key(&file) {
if self.analyzing.borrow().contains(&file) {
return None; // Circular import
}
self.analyze_file(&file);
}
let info = self.cache.borrow().get(&file)?.get(&name).cloned()?;
match info.re_export_source {
None => {
return self.class_selector(
&file,
&name,
info.value_local.as_deref()?,
implicit_standalone,
);
}
Some((source, original)) => {
if original == "*" {
return None;
}
file = self.resolve_module_spec(Path::new(&file).parent()?, &source)?;
name = original;
}
}
}
None
}

/// [`standalone_class_selector`] memoized per `(file, exported, local)`,
/// reading the source `analyze_file` kept instead of the file again.
fn class_selector(
&self,
file_path: &str,
exported: &str,
local: &str,
implicit_standalone: bool,
) -> Option<String> {
let key =
(file_path.to_string(), exported.to_string(), local.to_string(), implicit_standalone);
if let Some(cached) = self.selector_memo.borrow().get(&key) {
return cached.clone();
}
// `sources` is populated when `values_enabled`; read the file
// directly as a fallback, like `eval_export_uncached`.
let source = self
.sources
.borrow()
.get(file_path)
.cloned()
.or_else(|| std::fs::read_to_string(file_path).ok());
let selector = source.and_then(|source| {
standalone_class_selector(file_path, &source, exported, local, implicit_standalone)
});
self.selector_memo.borrow_mut().insert(key, selector.clone());
selector
}

/// Every value `import_source` exports, for `{ ...ns }` with
/// `import * as ns` (ngtsc's `ResolvedModule.getExports()`): each name
/// with the value it resolves to, or `None` for one that can't be read
Expand Down Expand Up @@ -1026,6 +1201,7 @@ impl Inner {
self.cache.borrow_mut().clear();
self.sources.borrow_mut().clear();
self.value_memo.borrow_mut().clear();
self.selector_memo.borrow_mut().clear();
}

/// Get the number of cached files.
Expand Down Expand Up @@ -1743,4 +1919,29 @@ export { doSomething } from './utils';
"{normalized:?} should contain the star-exported file"
);
}

#[test]
fn test_standalone_directive_selector_is_memoized() {
// The selector is read from the source `analyze_file` kept and
// memoized, not re-read from disk on every lookup, until the caches
// are cleared. The file is still reported as a dependency each time.
let dir = TempDir::new().unwrap();
let component = "import { Component } from '@angular/core';\n@Component({ selector: 'app-a' })\nexport class LazyCmp {}";
let lazy = create_test_file(dir.path(), "lazy.ts", component);
let main_file = dir.path().join("main.ts");
let mut analyzer = CrossFileAnalyzer::new(dir.path(), None, true);
let selector = |analyzer: &CrossFileAnalyzer| {
analyzer.standalone_directive_selector("./lazy", "LazyCmp", &main_file, true)
};

assert_eq!(selector(&analyzer).as_deref(), Some("app-a"));
assert_eq!(analyzer.take_value_dependencies().len(), 1);

fs::write(&lazy, component.replace("app-a", "app-b")).unwrap();
assert_eq!(selector(&analyzer).as_deref(), Some("app-a"));
assert_eq!(analyzer.take_value_dependencies().len(), 1);

analyzer.clear_cache();
assert_eq!(selector(&analyzer).as_deref(), Some("app-b"));
}
}
Loading
Loading