From 807cb2370ffc7a3014ec741c7e5e1874f9ad989a Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Sun, 25 Oct 2026 09:22:20 +0100 Subject: [PATCH 1/2] fix(defer): lazy-load an imports entry written as forwardRef(() => X) Resolve `@angular/core`'s `forwardRef` in `imports` before the implicit `@defer` deferral, like ngtsc's `forwardRefResolver`, so a component used only inside `@defer` is loaded with a dynamic `import()`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/decorator.rs | 22 ++++++++ .../src/component/metadata.rs | 7 +++ .../src/component/transform.rs | 49 ++++++++++++++--- .../oxc_angular_compiler/src/directive/mod.rs | 2 +- .../tests/defer_implicit_imports_test.rs | 53 +++++++++++++++++++ 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 2a617d1ea..8a2d33429 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -20,6 +20,7 @@ use super::transform::ImportMap; use crate::directive::{ HostKey, MemberHostBindings, StringConsts, evaluate_host_directives, extract_input_metadata_in, extract_output_metadata_in, merge_by_class_property, merge_host_bindings, parse_decorator_io, + try_unwrap_forward_ref, }; use crate::output::oxc_converter::convert_oxc_expression; use crate::util::is_metadata_property; @@ -178,6 +179,8 @@ pub fn extract_component_metadata<'a>( // For standalone components, we need: // 1. The identifier list for local analysis metadata.imports = extract_identifier_array(allocator, &prop.value); + metadata.forward_ref_imports = + extract_forward_ref_array(allocator, &prop.value, consts); // 2. The raw expression to pass to ɵɵgetComponentDepsFactory in RuntimeResolved mode metadata.raw_imports = convert_oxc_expression(allocator, &prop.value, source_text); @@ -487,6 +490,25 @@ fn extract_identifier_array<'a>( result } +/// The classes an array literal names as `forwardRef(() => X)`, with +/// `@angular/core`'s `forwardRef`. +fn extract_forward_ref_array<'a>( + allocator: &'a Allocator, + expr: &'a Expression<'a>, + consts: &StringConsts<'_>, +) -> Vec<'a, Ident<'a>> { + let mut result = Vec::new_in(&allocator); + if let Expression::ArrayExpression(arr) = expr { + for element in arr.elements.iter().filter_map(ArrayExpressionElement::as_expression) { + if let Some(Expression::Identifier(id)) = try_unwrap_forward_ref(element, Some(consts)) + { + result.push(id.name); + } + } + } + result +} + /// Extract ViewEncapsulation from an expression. fn extract_encapsulation(expr: &Expression<'_>) -> ViewEncapsulation { // Look for patterns like: diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index b97bc38d8..2e3d390c8 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -428,6 +428,12 @@ pub struct ComponentMetadata<'a> { /// Component imports (for standalone components). pub imports: Vec<'a, Ident<'a>>, + /// The classes written as `forwardRef(() => X)` (`@angular/core`'s) in + /// the `imports` array literal, which [`Self::imports`] leaves out. Only + /// read to let `@defer` lazy-load them, as ngtsc resolves them through + /// its `forwardRefResolver`. + pub forward_ref_imports: Vec<'a, Ident<'a>>, + /// Exported names for template references. /// /// In Angular, `exportAs` can be a comma-separated string (e.g., "foo, bar"), @@ -852,6 +858,7 @@ impl<'a> ComponentMetadata<'a> { change_detection: None, host: None, imports: Vec::new_in(&allocator), + forward_ref_imports: Vec::new_in(&allocator), export_as: Vec::new_in(&allocator), preserve_whitespaces: false, constructor_deps: None, diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 9bca18f5e..497c17890 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5372,6 +5372,39 @@ fn html_parse_error_diagnostic( /// entry refers to, by local name, when `@defer` may lazy-load it. type DeferrableSelector<'f> = &'f dyn Fn(&str) -> Option; +/// The class an `imports` entry names: `X`, or `forwardRef(() => X)` when +/// the decorator analysis resolved it through `@angular/core`'s `forwardRef` +/// (`forward_refs`), like ngtsc's `forwardRefResolver`. +fn import_entry_class<'a>( + entry: &OutputExpression<'a>, + forward_refs: &[Ident<'a>], +) -> Option> { + let read_var = |expr: &OutputExpression<'a>| match expr { + OutputExpression::ReadVar(var) => Some(var.name), + _ => None, + }; + if let Some(name) = read_var(entry) { + return Some(name); + } + let OutputExpression::InvokeFunction(call) = entry else { return None }; + let statements = match call.args.as_slice() { + [OutputExpression::ArrowFunction(arrow)] => match &arrow.body { + ArrowFunctionBody::Expression(body) => { + return read_var(body).filter(|name| forward_refs.contains(name)); + } + ArrowFunctionBody::Statements(statements) => statements, + }, + [OutputExpression::Function(function)] => &function.statements, + _ => return None, + }; + match statements.as_slice() { + [OutputStatement::Return(ret)] => { + read_var(&ret.value).filter(|name| forward_refs.contains(name)) + } + _ => None, + } +} + fn compile_component_full<'a>( allocator: &'a Allocator, template: &'a str, @@ -5552,15 +5585,18 @@ fn compile_component_full<'a>( && let Some(import_elision) = import_elision && !options.hmr && let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports - && raw_imports.entries.iter().all(|entry| matches!(entry, OutputExpression::ReadVar(_))) + && let Some(entry_classes) = raw_imports + .entries + .iter() + .map(|entry| import_entry_class(entry, &metadata.forward_ref_imports)) + .collect::>>() && super::defer_resolver::template_has_defer_block(&r3_result.nodes) { - let candidates: Vec<_> = metadata - .imports - .iter() + let candidates: Vec<_> = entry_classes + .into_iter() .filter_map(|name| { let selector = deferrable_selector(name.as_str())?; - Some((*name, crate::pipeline::selector::CssSelector::parse(&selector))) + Some((name, crate::pipeline::selector::CssSelector::parse(&selector))) }) .collect(); let deferred = @@ -5584,8 +5620,9 @@ fn compile_component_full<'a>( let is_deferred = |name: &str| deferred.iter().any(|d| d.as_str() == name); metadata.imports.retain(|name| !is_deferred(name.as_str())); if let Some(OutputExpression::LiteralArray(raw_imports)) = &mut metadata.raw_imports { + let forward_refs = &metadata.forward_ref_imports; raw_imports.entries.retain(|entry| { - !matches!(entry, OutputExpression::ReadVar(var) if is_deferred(var.name.as_str())) + !import_entry_class(entry, forward_refs).is_some_and(|name| is_deferred(&name)) }); } implicitly_deferred_imports = deferred.iter().map(ToString::to_string).collect(); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 7cdb5466e..ea74be1ac 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -55,7 +55,7 @@ pub(crate) use property_decorators::{ angular_class_decorator, angular_member_decorator, angular_param_decorator, decorator_written_name, is_angular_core_decorator, parse_decorator_queries, try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, - unwrap_initializer_api_expr, + try_unwrap_forward_ref, unwrap_initializer_api_expr, }; pub use property_decorators::{ extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, 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 e66f92da6..05ac8ed2f 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -223,6 +223,59 @@ export class Parent {} assert_lazy(&result.code, "./lazy", "default"); } +/// Port of ngtsc's `defer_spec.ts`: "should lazy-load dependency referenced +/// with a fowrardRef". +#[test] +fn forward_ref_import_used_only_inside_defer_is_lazy_loaded() { + for entry in ["forwardRef(() => LazyCmp)", "forwardRef(() => { return LazyCmp; })"] { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + &format!( + r#" +import {{ Component, forwardRef }} from '@angular/core'; +import {{ LazyCmp }} from './lazy'; + +@Component({{ + selector: 'app-parent', + imports: [{entry}], + template: '@defer {{ }}', +}}) +export class Parent {{}} +"# + ), + ); + let code = &result.code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!(compact(code).contains("ɵɵdefer(1,0,()=>[import(\"./lazy\")"), "{code}"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[])"), "{code}"); + assert!(!code.contains("import { LazyCmp }"), "{code}"); + } +} + +#[test] +fn forward_ref_not_from_angular_core_stays_eager() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + let result = transform( + &dir, + r#" +import { Component } from '@angular/core'; +import { forwardRef } from './my-forward-ref'; +import { LazyCmp } from './lazy'; + +@Component({ + selector: 'app-parent', + imports: [forwardRef(() => LazyCmp)], + template: '@defer { }', +}) +export class Parent {} +"#, + ); + assert_eager(&result.code, "./lazy"); +} + #[test] fn import_referenced_elsewhere_in_the_file_stays_eager() { let dir = TempDir::new().unwrap(); From d5d52eede975180a3fc9bcf60ff388f720fc079a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 27 Oct 2026 15:56:40 +0800 Subject: [PATCH 2/2] fix(defer): read each imports entry's class from the source by position The converter turns a `function` expression into raw source, so `forwardRef(function () { return X; })` never matched and stayed eager, while ngtsc lazy-loads it. Record the class of each `imports` element from the decorator AST, where `try_unwrap_forward_ref` reads every form, and match the converted entries by position. Other entries keep the old rule: a plain name in the output stays eager (`X as any`, like ngtsc) without blocking the rest. Co-Authored-By: Claude Opus 5.5 --- .../src/component/decorator.rs | 25 +++++---- .../src/component/metadata.rs | 13 ++--- .../src/component/transform.rs | 53 +++++-------------- .../tests/defer_implicit_imports_test.rs | 41 +++++++++++++- 4 files changed, 73 insertions(+), 59 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index d878416a5..e6a2367cc 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -180,8 +180,8 @@ pub fn extract_component_metadata<'a>( // For standalone components, we need: // 1. The identifier list for local analysis metadata.imports = extract_identifier_array(allocator, &prop.value); - metadata.forward_ref_imports = - extract_forward_ref_array(allocator, &prop.value, consts); + metadata.import_entry_classes = + extract_import_entry_classes(allocator, &prop.value, consts); // 2. The raw expression to pass to ɵɵgetComponentDepsFactory in RuntimeResolved mode metadata.raw_imports = convert_oxc_expression(allocator, &prop.value, source_text); @@ -491,20 +491,23 @@ fn extract_identifier_array<'a>( result } -/// The classes an array literal names as `forwardRef(() => X)`, with -/// `@angular/core`'s `forwardRef`. -fn extract_forward_ref_array<'a>( +/// The class each element of an array literal names, by position: `X`, or +/// `forwardRef(() => X)` with `@angular/core`'s `forwardRef`. +fn extract_import_entry_classes<'a>( allocator: &'a Allocator, expr: &'a Expression<'a>, consts: &StringConsts<'_>, -) -> Vec<'a, Ident<'a>> { +) -> Vec<'a, Option>> { let mut result = Vec::new_in(&allocator); if let Expression::ArrayExpression(arr) = expr { - for element in arr.elements.iter().filter_map(ArrayExpressionElement::as_expression) { - if let Some(Expression::Identifier(id)) = try_unwrap_forward_ref(element, Some(consts)) - { - result.push(id.name); - } + for element in &arr.elements { + let class = element.as_expression().and_then(|element| { + match try_unwrap_forward_ref(element, Some(consts)).unwrap_or(element) { + Expression::Identifier(id) => Some(id.name), + _ => None, + } + }); + result.push(class); } } result diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index d4ae7c985..1e1d60453 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -526,11 +526,12 @@ pub struct ComponentMetadata<'a> { /// Component imports (for standalone components). pub imports: Vec<'a, Ident<'a>>, - /// The classes written as `forwardRef(() => X)` (`@angular/core`'s) in - /// the `imports` array literal, which [`Self::imports`] leaves out. Only - /// read to let `@defer` lazy-load them, as ngtsc resolves them through - /// its `forwardRefResolver`. - pub forward_ref_imports: Vec<'a, Ident<'a>>, + /// The class `@defer` may lazy-load for each element of the `imports` + /// array literal, by position: `X`, or `forwardRef(() => X)` with + /// `@angular/core`'s `forwardRef` (which [`Self::imports`] leaves out), + /// as ngtsc resolves them through its `forwardRefResolver`; `None` for + /// anything else, such as `X as any`, which ngtsc keeps eager. + pub import_entry_classes: Vec<'a, Option>>, /// Exported names for template references. /// @@ -960,7 +961,7 @@ impl<'a> ComponentMetadata<'a> { change_detection: None, host: None, imports: Vec::new_in(&allocator), - forward_ref_imports: Vec::new_in(&allocator), + import_entry_classes: Vec::new_in(&allocator), export_as: Vec::new_in(&allocator), preserve_whitespaces: None, constructor_deps: None, diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 10444cc8a..097f5254f 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5445,39 +5445,6 @@ fn html_parse_error_diagnostic( /// entry refers to, by local name, when `@defer` may lazy-load it. type DeferrableSelector<'f> = &'f dyn Fn(&str) -> Option; -/// The class an `imports` entry names: `X`, or `forwardRef(() => X)` when -/// the decorator analysis resolved it through `@angular/core`'s `forwardRef` -/// (`forward_refs`), like ngtsc's `forwardRefResolver`. -fn import_entry_class<'a>( - entry: &OutputExpression<'a>, - forward_refs: &[Ident<'a>], -) -> Option> { - let read_var = |expr: &OutputExpression<'a>| match expr { - OutputExpression::ReadVar(var) => Some(var.name), - _ => None, - }; - if let Some(name) = read_var(entry) { - return Some(name); - } - let OutputExpression::InvokeFunction(call) = entry else { return None }; - let statements = match call.args.as_slice() { - [OutputExpression::ArrowFunction(arrow)] => match &arrow.body { - ArrowFunctionBody::Expression(body) => { - return read_var(body).filter(|name| forward_refs.contains(name)); - } - ArrowFunctionBody::Statements(statements) => statements, - }, - [OutputExpression::Function(function)] => &function.statements, - _ => return None, - }; - match statements.as_slice() { - [OutputStatement::Return(ret)] => { - read_var(&ret.value).filter(|name| forward_refs.contains(name)) - } - _ => None, - } -} - fn compile_component_full<'a>( allocator: &'a Allocator, template: &'a str, @@ -5669,15 +5636,19 @@ fn compile_component_full<'a>( && let Some(import_elision) = import_elision && !options.hmr && let Some(OutputExpression::LiteralArray(raw_imports)) = &metadata.raw_imports - && let Some(entry_classes) = raw_imports + && raw_imports.entries.len() == metadata.import_entry_classes.len() + && raw_imports .entries .iter() - .map(|entry| import_entry_class(entry, &metadata.forward_ref_imports)) - .collect::>>() + .zip(&metadata.import_entry_classes) + .all(|(entry, class)| class.is_some() || matches!(entry, OutputExpression::ReadVar(_))) && super::defer_resolver::template_has_defer_block(&r3_result.nodes) { - let candidates: Vec<_> = entry_classes - .into_iter() + let candidates: Vec<_> = metadata + .import_entry_classes + .iter() + .flatten() + .copied() .filter_map(|name| { let selector = deferrable_selector(name.as_str())?; Some((name, crate::pipeline::selector::CssSelector::parse(&selector))) @@ -5704,9 +5675,9 @@ fn compile_component_full<'a>( let is_deferred = |name: &str| deferred.iter().any(|d| d.as_str() == name); metadata.imports.retain(|name| !is_deferred(name.as_str())); if let Some(OutputExpression::LiteralArray(raw_imports)) = &mut metadata.raw_imports { - let forward_refs = &metadata.forward_ref_imports; - raw_imports.entries.retain(|entry| { - !import_entry_class(entry, forward_refs).is_some_and(|name| is_deferred(&name)) + let mut classes = metadata.import_entry_classes.iter(); + raw_imports.entries.retain(|_| { + !classes.next().copied().flatten().is_some_and(|name| is_deferred(&name)) }); } implicitly_deferred_imports = deferred.iter().map(ToString::to_string).collect(); 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 05ac8ed2f..6f141ccd2 100644 --- a/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/defer_implicit_imports_test.rs @@ -227,7 +227,11 @@ export class Parent {} /// with a fowrardRef". #[test] fn forward_ref_import_used_only_inside_defer_is_lazy_loaded() { - for entry in ["forwardRef(() => LazyCmp)", "forwardRef(() => { return LazyCmp; })"] { + for entry in [ + "forwardRef(() => LazyCmp)", + "forwardRef(() => { return LazyCmp; })", + "forwardRef(function () { return LazyCmp; })", + ] { let dir = TempDir::new().unwrap(); lazy_cmp(&dir); let result = transform( @@ -254,6 +258,41 @@ export class Parent {{}} } } +#[test] +fn forward_ref_entry_next_to_a_cast_entry_is_lazy_loaded_alone() { + let dir = TempDir::new().unwrap(); + lazy_cmp(&dir); + create_test_file( + dir.path(), + "app/eager.ts", + r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-eager', template: 'eager' }) +export class EagerCmp {} +"#, + ); + let result = transform( + &dir, + r#" +import { Component, forwardRef } from '@angular/core'; +import { LazyCmp } from './lazy'; +import { EagerCmp } from './eager'; + +@Component({ + selector: 'app-parent', + imports: [forwardRef(() => LazyCmp), EagerCmp as any], + template: '@defer { }', +}) +export class Parent {} +"#, + ); + let code = &result.code; + assert_lazy(code, "./lazy", "LazyCmp"); + assert!(compact(code).contains("ɵɵgetComponentDepsFactory(Parent,[EagerCmp])"), "{code}"); + assert!(code.contains("import { EagerCmp }"), "{code}"); +} + #[test] fn forward_ref_not_from_angular_core_stays_eager() { let dir = TempDir::new().unwrap();