diff --git a/crates/oxc_angular_compiler/src/component/defer_resolver.rs b/crates/oxc_angular_compiler/src/component/defer_resolver.rs index f9d53d259..b78871859 100644 --- a/crates/oxc_angular_compiler/src/component/defer_resolver.rs +++ b/crates/oxc_angular_compiler/src/component/defer_resolver.rs @@ -77,6 +77,35 @@ pub fn template_has_defer_block<'a>(nodes: &[R3Node<'a>]) -> bool { visitor.found } +/// Source spans of every `@defer` block, in the order Angular's binder lists +/// them (`getDeferBlocks()`): a block comes before the blocks nested in it, and +/// its main content before its `@placeholder`, `@loading` and `@error`. +pub fn defer_block_spans(nodes: &[R3Node<'_>]) -> std::vec::Vec { + struct DeferCollector { + spans: std::vec::Vec, + } + + impl<'a> R3Visitor<'a> for DeferCollector { + fn visit_deferred_block(&mut self, block: &R3DeferredBlock<'a>) { + self.spans.push(block.source_span); + visit_all(self, &block.children); + if let Some(placeholder) = &block.placeholder { + self.visit_deferred_block_placeholder(placeholder); + } + if let Some(loading) = &block.loading { + self.visit_deferred_block_loading(loading); + } + if let Some(error) = &block.error { + self.visit_deferred_block_error(error); + } + } + } + + let mut visitor = DeferCollector { spans: std::vec::Vec::new() }; + visit_all(&mut visitor, nodes); + visitor.spans +} + /// Build the list of `R3DeferPerComponentDependency` describing the /// `@Component.deferredImports` entries, used to emit `setClassMetadataAsync` /// and the per-component `ɵɵdefer` resolver. diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 68d7e7c03..dfaf8dbb3 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -57,7 +57,7 @@ use crate::ng_module::{ }; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, DeclareFunctionStmt, FunctionExpr, OutputExpression, - OutputStatement, ReadPropExpr, ReadVarExpr, StmtModifier, + OutputStatement, RawSourceExpr, ReadPropExpr, ReadVarExpr, StmtModifier, }; use crate::output::emitter::JsEmitter; use crate::parser::ParseTemplateOptions; @@ -5666,6 +5666,7 @@ fn compile_component_full<'a>( // PerComponent mode: pass the shared resolver expression so the // ingest stage attaches it to every defer block's create op. all_deferrable_deps_fn: defer_resolver_fn, + defer_blocks: FxHashMap::default(), // Use the shared pool starting index to avoid duplicate constant names // when compiling multiple components in the same file pool_starting_index, @@ -6221,6 +6222,7 @@ pub fn compile_template_to_js_with_options<'a>( enable_debug_locations, template_source: Some(template), all_deferrable_deps_fn: None, + defer_blocks: FxHashMap::default(), pool_starting_index: 0, // Standalone template compilation starts from 0 angular_version: options.angular_version, legacy_optional_chaining: options.legacy_optional_chaining, @@ -6407,6 +6409,7 @@ pub fn compile_template_for_hmr<'a>( enable_debug_locations, template_source: Some(template), all_deferrable_deps_fn: None, + defer_blocks: FxHashMap::default(), pool_starting_index: 0, // HMR template compilation starts from 0 angular_version: options.angular_version, legacy_optional_chaining: options.legacy_optional_chaining, @@ -7071,6 +7074,7 @@ pub fn compile_template_for_linker<'a>( preserve_whitespaces: bool, core_namespace: &str, angular_version: Option, + defer_block_deps: &[Option<&str>], ) -> Result> { use crate::pipeline::ingest::{IngestOptions, ingest_component_with_options}; use oxc_allocator::FromIn; @@ -7119,6 +7123,23 @@ pub fn compile_template_for_linker<'a>( return Err(diagnostics); } + // Like the TS linker's `createR3ComponentDeferMetadata`: the i-th entry of + // `deferBlockDependencies` belongs to the i-th `@defer` block, and a block + // past the end of the list (or a `null` entry) has no dependency function. + let defer_blocks = super::defer_resolver::defer_block_spans(&r3_result.nodes) + .into_iter() + .enumerate() + .map(|(i, span)| { + let deps_fn = defer_block_deps.get(i).copied().flatten().map(|source| { + OutputExpression::RawSource(oxc_allocator::Box::new_in( + RawSourceExpr { source: Ident::from_in(source, &allocator), source_span: None }, + &allocator, + )) + }); + (span, deps_fn) + }) + .collect(); + // Stage 3-5: Ingest and compile let ingest_options = IngestOptions { mode: TemplateCompilationMode::Full, @@ -7129,6 +7150,7 @@ pub fn compile_template_for_linker<'a>( enable_debug_locations: false, template_source: Some(template), all_deferrable_deps_fn: None, + defer_blocks, pool_starting_index: 0, angular_version, // Angular's linker uses native `?.` for declarations at version 22+ (and the diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 0e9e43695..25dfbd3f1 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -43,6 +43,7 @@ use oxc_span::{GetSpan, SourceType}; use crate::AngularVersion; use crate::optimizer::Edit; +use crate::output::emitter::escape_string; use crate::pipeline::phases::parse_extracted_styles::parse_style_string; use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector}; @@ -55,7 +56,7 @@ fn needs_object_key_quoting(key: &str) -> bool { /// Quote a property key if it contains unsafe characters (dots or hyphens). fn quote_key(key: &str) -> String { - if needs_object_key_quoting(key) { format!("\"{key}\"") } else { key.to_string() } + if needs_object_key_quoting(key) { escape_string(key, false) } else { key.to_string() } } /// Partial declaration function names to link. @@ -882,7 +883,7 @@ fn parse_selector(selector: &str) -> String { let parts: Vec = elements .iter() .map(|el| match el { - R3SelectorElement::String(s) => format!("\"{s}\""), + R3SelectorElement::String(s) => escape_string(s, false), R3SelectorElement::Flag(f) => f.to_string(), }) .collect(); @@ -940,7 +941,7 @@ fn build_host_attrs(host_obj: &ObjectExpression<'_>, source: &str) -> String { // Split classes and add each separately attrs.push("1".to_string()); // AttributeMarker.Classes for class in class_attr.split_whitespace() { - attrs.push(format!("\"{class}\"")); + attrs.push(escape_string(class, false)); } } @@ -1287,7 +1288,7 @@ fn link_pipe( ns: &str, type_name: &str, ) -> Option { - let pipe_name = get_string_property(meta, "name")?; + let pipe_name = escape_string(get_string_property(meta, "name")?, false); let pure = get_property_source(meta, "pure", source).unwrap_or("true"); let standalone = get_bool_property(meta, "isStandalone") .unwrap_or_else(|| get_default_standalone_value(meta)); @@ -1296,7 +1297,7 @@ fn link_pipe( if standalone { String::new() } else { ", standalone: false".to_string() }; Some(format!( - "/*@__PURE__*/ {ns}.\u{0275}\u{0275}definePipe({{ name: \"{pipe_name}\", type: {type_name}, pure: {pure}{standalone_part} }})" + "/*@__PURE__*/ {ns}.\u{0275}\u{0275}definePipe({{ name: {pipe_name}, type: {type_name}, pure: {pure}{standalone_part} }})" )) } @@ -1877,7 +1878,7 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option // emits `ɵɵControlFeature("formField")` or `ɵɵControlFeature(null)` if let Some(control_create) = get_object_property(meta, "controlCreate") { let pass_through = match get_string_property(control_create, "passThroughInput") { - Some(s) => format!("\"{s}\""), + Some(s) => escape_string(s, false), None => "null".to_string(), }; features.push(format!("{ns}.\u{0275}\u{0275}ControlFeature({pass_through})")); @@ -1918,6 +1919,23 @@ fn link_component( let template_owned: String = template.to_string(); let template_ref: &str = template_allocator.alloc_str(&template_owned); + // `deferBlockDependencies: [() => [import("./a").then(m => m.A)], null]`, + // one entry per `@defer` block, emitted as written. + let defer_block_deps: Vec> = get_array_property(meta, "deferBlockDependencies") + .map(|deps| { + deps.elements + .iter() + .map(|el| match el.as_expression() { + Some(Expression::NullLiteral(_)) | None => None, + Some(expr) => { + let span = expr.span(); + Some(&source[span.start as usize..span.end as usize]) + } + }) + .collect() + }) + .unwrap_or_default(); + let template_output = crate::component::compile_template_for_linker( &template_allocator, template_ref, @@ -1926,6 +1944,7 @@ fn link_component( preserve_whitespaces, ns, options.angular_version, + &defer_block_deps, ) .ok()?; diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index aa4d45afe..4c2567b6b 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -107,6 +107,10 @@ pub struct IngestOptions<'a> { /// Ported from Angular's ingestComponent parameter (line 65 in ingest.ts). pub all_deferrable_deps_fn: Option>, + /// Dependency function of each `@defer` block, keyed by the block's source + /// span. Only read in `PerBlock` mode. + pub defer_blocks: FxHashMap>>, + /// Starting index for the constant pool's name counter. /// /// This is used when compiling multiple components in the same file to ensure @@ -145,6 +149,7 @@ impl Default for IngestOptions<'_> { enable_debug_locations: false, template_source: None, all_deferrable_deps_fn: None, + defer_blocks: FxHashMap::default(), pool_starting_index: 0, angular_version: None, legacy_optional_chaining: None, @@ -891,7 +896,7 @@ pub fn ingest_component_with_options<'a>( // The all_deferrable_deps_fn is stored on the job and referenced during emit job.defer_meta = match options.defer_block_deps_emit_mode { DeferBlockDepsEmitMode::PerBlock => { - DeferMetadata::PerBlock { blocks: FxHashMap::default() } + DeferMetadata::PerBlock { blocks: options.defer_blocks } } DeferBlockDepsEmitMode::PerComponent => { // In PerComponent mode, dependencies_fn is set from all_deferrable_deps_fn during emit diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index b98c0f551..158d390d3 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -551,3 +551,52 @@ export { P }; result.code ); } + +#[test] +fn test_link_escapes_metadata_strings() { + let code = r#"import * as i0 from "@angular/core"; +export class P {} +P.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.0", ngImport: i0, type: P, isStandalone: true, name: 'a"+x+"' }); +export class D {} +D.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", ngImport: i0, type: D, isStandalone: true, selector: "[t='a\"b']", inputs: { 'a.b"c': "c" }, host: { classAttribute: 'k"+x+"' }, controlCreate: { passThroughInput: 'f"+x+"' } }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + for expected in [ + r#"name: "a\"+x+\"""#, + r#"selectors: [["", "t", "a\"b"]]"#, + r#"inputs: { "a.b\"c": "c" }"#, + r#"hostAttrs: [1, "k\"+x+\""]"#, + r#"ɵɵControlFeature("f\"+x+\"")"#, + ] { + assert!(result.code.contains(expected), "missing `{expected}` in\n{}", result.code); + } +} + +#[test] +fn test_link_defer_block_dependencies() { + // `deferBlockDependencies` follows the binder's block order: the outer + // block, the block nested in it, the block in its placeholder, then the + // next top-level block. + let code = r#"import * as i0 from "@angular/core"; +export class C {} +C.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.2", ngImport: i0, type: C, isStandalone: true, selector: "c", template: "@defer { @defer { } } @placeholder { @defer { } } @defer (on idle) { }", deferBlockDependencies: [() => [import("./lazy").then(m => m.LazyCmp)], null, () => [import("./b").then(m => m.B)], () => [import("./x").then(m => m.X), import("./y").then(m => m.Y)]] }); +"#; + let allocator = Allocator::default(); + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + let linked: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + // Fragments of the TS linker's output. + for expected in [ + r#"constC_Defer_2_DepsFn=()=>[import("./lazy").then(m=>m.LazyCmp)];"#, + r#"constC_Defer_5_DepsFn=()=>[import("./x").then(m=>m.X),import("./y").then(m=>m.Y)];"#, + r#"constC_DeferPlaceholder_1_Defer_1_DepsFn=()=>[import("./b").then(m=>m.B)];"#, + "i0.ɵɵdefer(2,1);", + "i0.ɵɵdefer(1,0,C_DeferPlaceholder_1_Defer_1_DepsFn);", + "i0.ɵɵdefer(2,0,C_Defer_2_DepsFn,null,1);", + "i0.ɵɵdefer(5,4,C_Defer_5_DepsFn);", + ] { + assert!(linked.contains(expected), "missing `{expected}` in\n{}", result.code); + } +}