From 81949b41477fc3819da4f6cdd8c7f7d0ec51d28e Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Mon, 19 Oct 2026 15:50:07 +0200 Subject: [PATCH 1/4] fix(linker): link with the application's `angularVersion` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker always compiled partial templates and host bindings for the latest Angular, so since 22.2.2's security schema change it dropped `ɵɵsanitizeUrl` from `img`/`video` `[src]` in every linked library. Runtimes before 22.2.2 lack the shim that compensates for it, so a `SafeUrl` bound to `[src]` was assigned as the raw `SafeValue` object. Add `LinkOptions { angular_version }` (napi: optional `options` argument to `linkAngularPackage{,Sync}`) and forward the Vite plugin's `angularVersion` to both linker hooks. Optional chaining keeps its legacy semantics: Angular's linker derives them from the declaration's own version, not the app's. Fixes #586 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 16 +- crates/oxc_angular_compiler/src/lib.rs | 2 +- crates/oxc_angular_compiler/src/linker/mod.rs | 138 ++++++++++++------ .../oxc_angular_compiler/tests/linker_test.rs | 88 ++++++++++- napi/angular-compiler/README.md | 1 + napi/angular-compiler/index.d.ts | 25 +++- napi/angular-compiler/src/lib.rs | 36 ++++- napi/angular-compiler/test/linker.test.ts | 64 ++++++++ .../vite-plugin/angular-linker-plugin.ts | 16 +- napi/angular-compiler/vite-plugin/index.ts | 2 +- 10 files changed, 320 insertions(+), 68 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 554193715..30dfb3269 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6296,6 +6296,7 @@ pub fn compile_host_bindings_for_linker( selector: Option<&str>, pool_starting_index: u32, core_namespace: &str, + angular_version: Option, ) -> Option { let allocator = Allocator::default(); let result = compile_host_bindings_from_input( @@ -6305,8 +6306,10 @@ pub fn compile_host_bindings_for_linker( component_name, selector, pool_starting_index, - None, // Linker always targets latest Angular version - None, // legacyOptionalChaining: derive from (absent) version + angular_version, + // `?.` semantics follow the declaration's own `version` in Angular's linker, not + // the application's, so `angular_version` must not switch them: keep legacy. + Some(true), )?; let emitter = JsEmitter::with_core_namespace(core_namespace); @@ -6370,6 +6373,7 @@ pub fn compile_template_for_linker<'a>( file_path: &str, preserve_whitespaces: bool, core_namespace: &str, + angular_version: Option, ) -> Result> { use crate::pipeline::ingest::{IngestOptions, ingest_component_with_options}; use oxc_allocator::FromIn; @@ -6406,7 +6410,7 @@ pub fn compile_template_for_linker<'a>( // Stage 2: Transform HTML to R3 AST let r3_transform_options = R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes, - angular_version: None, + angular_version, }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -6429,8 +6433,10 @@ pub fn compile_template_for_linker<'a>( template_source: Some(template), all_deferrable_deps_fn: None, pool_starting_index: 0, - angular_version: None, - legacy_optional_chaining: None, + angular_version, + // `?.` semantics follow the declaration's own `version` in Angular's linker, not + // the application's, so `angular_version` must not switch them: keep legacy. + legacy_optional_chaining: Some(true), }; let component_name_atom = Ident::from_in(component_name, &allocator); diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 437626eab..a34f97c69 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -172,7 +172,7 @@ pub use dts::{ pub use dts_inject::{DtsInjectDeclaration, inject_dts_declarations}; // Re-export linker types -pub use linker::{LinkResult, link}; +pub use linker::{LinkOptions, LinkResult, link, link_with_options}; // Re-export optimizer types pub use optimizer::{OptimizeOptions, OptimizeResult, optimize}; diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 2fd8369a8..9478cb273 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -41,6 +41,7 @@ use oxc_ast::ast::{ use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; +use crate::AngularVersion; use crate::optimizer::Edit; use crate::pipeline::selector::{R3SelectorElement, parse_selector_to_r3_selector}; @@ -79,11 +80,33 @@ pub struct LinkResult { pub linked: bool, } +/// Options for linking Angular partial declarations. +#[derive(Debug, Clone, Copy, Default)] +pub struct LinkOptions { + /// Angular version of the consuming application. + /// + /// Like Angular's own linker, which compiles partial templates with the + /// application's `@angular/compiler`, this selects version-dependent emit + /// such as the DOM security schema (`img|src` is sanitized before 22.2.2). + /// `None` assumes the latest Angular version. + pub angular_version: Option, +} + /// Link Angular partial declarations in a JavaScript file. /// /// Scans the code for `ɵɵngDeclare*` calls and replaces them with their /// fully compiled equivalents. pub fn link(allocator: &Allocator, code: &str, filename: &str) -> LinkResult { + link_with_options(allocator, code, filename, &LinkOptions::default()) +} + +/// Link Angular partial declarations in a JavaScript file with explicit [`LinkOptions`]. +pub fn link_with_options( + allocator: &Allocator, + code: &str, + filename: &str, + options: &LinkOptions, +) -> LinkResult { // Quick check: if no declarations, return early. Match without the `ɵɵ` prefix because // minifiers may write it as `\u0275` escapes; the AST below has them decoded. if !code.contains("ngDeclare") { @@ -101,7 +124,7 @@ pub fn link(allocator: &Allocator, code: &str, filename: &str) -> LinkResult { let mut edits: Vec = Vec::new(); // Walk all statements looking for ɵɵngDeclare* calls - collect_declaration_edits(&program, code, filename, &mut edits); + collect_declaration_edits(&program, code, filename, options, &mut edits); if edits.is_empty() { return LinkResult { code: code.to_string(), map: None, linked: false }; @@ -117,109 +140,116 @@ fn collect_declaration_edits( program: &Program<'_>, source: &str, filename: &str, + options: &LinkOptions, edits: &mut Vec, ) { for stmt in &program.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } /// Walk a statement looking for ɵɵngDeclare* calls. -fn walk_statement(stmt: &Statement<'_>, source: &str, filename: &str, edits: &mut Vec) { +fn walk_statement( + stmt: &Statement<'_>, + source: &str, + filename: &str, + options: &LinkOptions, + edits: &mut Vec, +) { match stmt { Statement::ExpressionStatement(expr_stmt) => { - walk_expression(&expr_stmt.expression, source, filename, edits); + walk_expression(&expr_stmt.expression, source, filename, options, edits); } Statement::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, edits); + walk_class_body(&class_decl.body, source, filename, options, edits); } Statement::VariableDeclaration(var_decl) => { for decl in &var_decl.declarations { if let Some(init) = &decl.init { - walk_expression(init, source, filename, edits); + walk_expression(init, source, filename, options, edits); } } } Statement::ReturnStatement(ret) => { if let Some(ref arg) = ret.argument { - walk_expression(arg, source, filename, edits); + walk_expression(arg, source, filename, options, edits); } } Statement::BlockStatement(block) => { for stmt in &block.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } Statement::IfStatement(if_stmt) => { - walk_statement(&if_stmt.consequent, source, filename, edits); + walk_statement(&if_stmt.consequent, source, filename, options, edits); if let Some(ref alt) = if_stmt.alternate { - walk_statement(alt, source, filename, edits); + walk_statement(alt, source, filename, options, edits); } } Statement::ForStatement(for_stmt) => { - walk_statement(&for_stmt.body, source, filename, edits); + walk_statement(&for_stmt.body, source, filename, options, edits); } Statement::ForInStatement(for_in) => { - walk_statement(&for_in.body, source, filename, edits); + walk_statement(&for_in.body, source, filename, options, edits); } Statement::ForOfStatement(for_of) => { - walk_statement(&for_of.body, source, filename, edits); + walk_statement(&for_of.body, source, filename, options, edits); } Statement::WhileStatement(while_stmt) => { - walk_statement(&while_stmt.body, source, filename, edits); + walk_statement(&while_stmt.body, source, filename, options, edits); } Statement::DoWhileStatement(do_while) => { - walk_statement(&do_while.body, source, filename, edits); + walk_statement(&do_while.body, source, filename, options, edits); } Statement::TryStatement(try_stmt) => { for stmt in &try_stmt.block.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } if let Some(ref handler) = try_stmt.handler { for stmt in &handler.body.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } if let Some(ref finalizer) = try_stmt.finalizer { for stmt in &finalizer.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } Statement::SwitchStatement(switch_stmt) => { for case in &switch_stmt.cases { for stmt in &case.consequent { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } Statement::LabeledStatement(labeled) => { - walk_statement(&labeled.body, source, filename, edits); + walk_statement(&labeled.body, source, filename, options, edits); } Statement::FunctionDeclaration(func_decl) => { if let Some(ref body) = func_decl.body { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } Statement::ExportDeclaration(export_decl) => { - walk_declaration(&export_decl.declaration, source, filename, edits); + walk_declaration(&export_decl.declaration, source, filename, options, edits); } Statement::ExportDefaultDeclaration(export_default) => match &export_default.declaration { oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, edits); + walk_class_body(&class_decl.body, source, filename, options, edits); } oxc_ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(func_decl) => { if let Some(ref body) = func_decl.body { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } _ => { if let Some(expr) = export_default.declaration.as_expression() { - walk_expression(expr, source, filename, edits); + walk_expression(expr, source, filename, options, edits); } } }, @@ -232,24 +262,25 @@ fn walk_class_body( body: &oxc_ast::ast::ClassBody<'_>, source: &str, filename: &str, + options: &LinkOptions, edits: &mut Vec, ) { for element in &body.body { if let oxc_ast::ast::ClassElement::PropertyDefinition(prop) = element && let Some(ref value) = prop.value { - walk_expression(value, source, filename, edits); + walk_expression(value, source, filename, options, edits); } if let oxc_ast::ast::ClassElement::StaticBlock(block) = element { for stmt in &block.body { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } if let oxc_ast::ast::ClassElement::MethodDefinition(method) = element && let Some(ref body) = method.value.body { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } @@ -260,36 +291,43 @@ fn walk_declaration( decl: &oxc_ast::ast::Declaration<'_>, source: &str, filename: &str, + options: &LinkOptions, edits: &mut Vec, ) { match decl { oxc_ast::ast::Declaration::VariableDeclaration(var_decl) => { for d in &var_decl.declarations { if let Some(init) = &d.init { - walk_expression(init, source, filename, edits); + walk_expression(init, source, filename, options, edits); } } } oxc_ast::ast::Declaration::FunctionDeclaration(func_decl) => { if let Some(ref body) = func_decl.body { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } oxc_ast::ast::Declaration::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, edits); + walk_class_body(&class_decl.body, source, filename, options, edits); } _ => {} } } /// Walk an expression looking for ɵɵngDeclare* calls. -fn walk_expression(expr: &Expression<'_>, source: &str, filename: &str, edits: &mut Vec) { +fn walk_expression( + expr: &Expression<'_>, + source: &str, + filename: &str, + options: &LinkOptions, + edits: &mut Vec, +) { match expr { Expression::CallExpression(call) => { if let Some(name) = get_declare_name(call) - && let Some(edit) = link_declaration(name, call, source, filename) + && let Some(edit) = link_declaration(name, call, source, filename, options) { edits.push(edit); return; @@ -299,46 +337,46 @@ fn walk_expression(expr: &Expression<'_>, source: &str, filename: &str, edits: & if let Argument::SpreadElement(_) = arg { continue; } - walk_expression(arg.to_expression(), source, filename, edits); + walk_expression(arg.to_expression(), source, filename, options, edits); } } Expression::AssignmentExpression(assign) => { - walk_expression(&assign.right, source, filename, edits); + walk_expression(&assign.right, source, filename, options, edits); } Expression::SequenceExpression(seq) => { for expr in &seq.expressions { - walk_expression(expr, source, filename, edits); + walk_expression(expr, source, filename, options, edits); } } Expression::ConditionalExpression(cond) => { - walk_expression(&cond.consequent, source, filename, edits); - walk_expression(&cond.alternate, source, filename, edits); + walk_expression(&cond.consequent, source, filename, options, edits); + walk_expression(&cond.alternate, source, filename, options, edits); } Expression::LogicalExpression(logical) => { - walk_expression(&logical.left, source, filename, edits); - walk_expression(&logical.right, source, filename, edits); + walk_expression(&logical.left, source, filename, options, edits); + walk_expression(&logical.right, source, filename, options, edits); } Expression::ParenthesizedExpression(paren) => { - walk_expression(&paren.expression, source, filename, edits); + walk_expression(&paren.expression, source, filename, options, edits); } Expression::ArrowFunctionExpression(arrow) => { if let Some(body) = arrow.get_function_body() { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } else if let Some(expr) = arrow.get_expression() { - walk_expression(expr, source, filename, edits); + walk_expression(expr, source, filename, options, edits); } } Expression::FunctionExpression(func) => { if let Some(body) = &func.body { for stmt in &body.statements { - walk_statement(stmt, source, filename, edits); + walk_statement(stmt, source, filename, options, edits); } } } Expression::ClassExpression(class_expr) => { - walk_class_body(&class_expr.body, source, filename, edits); + walk_class_body(&class_expr.body, source, filename, options, edits); } _ => {} } @@ -917,6 +955,7 @@ fn link_declaration( call: &CallExpression<'_>, source: &str, filename: &str, + options: &LinkOptions, ) -> Option { let meta = get_metadata_object(call)?; let ns = get_ng_import_namespace(call); @@ -931,8 +970,8 @@ fn link_declaration( DECLARE_PIPE => link_pipe(meta, source, ns, type_name), DECLARE_CLASS_METADATA => link_class_metadata(meta, source, ns, type_name), DECLARE_CLASS_METADATA_ASYNC => link_class_metadata_async(meta, source, ns, type_name), - DECLARE_DIRECTIVE => link_directive(meta, source, ns, type_name), - DECLARE_COMPONENT => link_component(meta, source, filename, ns, type_name), + DECLARE_DIRECTIVE => link_directive(meta, source, ns, type_name, options), + DECLARE_COMPONENT => link_component(meta, source, filename, ns, type_name, options), _ => return None, }; @@ -1449,6 +1488,7 @@ fn link_directive( source: &str, ns: &str, type_name: &str, + options: &LinkOptions, ) -> Option { let mut parts = vec![format!("type: {type_name}")]; @@ -1512,6 +1552,7 @@ fn link_directive( selector, 0, // directives have no template, so pool starts at 0 ns, + options.angular_version, ) { if host_output.host_vars > 0 { parts.push(format!("hostVars: {}", host_output.host_vars)); @@ -1812,6 +1853,7 @@ fn link_component( filename: &str, ns: &str, type_name: &str, + options: &LinkOptions, ) -> Option { // Extract template string - required for component linking let template = get_string_property(meta, "template")?; @@ -1831,6 +1873,7 @@ fn link_component( filename, preserve_whitespaces, ns, + options.angular_version, ) .ok()?; @@ -1879,6 +1922,7 @@ fn link_component( selector, template_output.next_pool_index, ns, + options.angular_version, ) { if host_output.host_vars > 0 { parts.push(format!("hostVars: {}", host_output.host_vars)); diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index e408eac1e..6dd142bd8 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -1,7 +1,8 @@ -//! Tests for Angular linker input/output key quoting. +//! Tests for the Angular linker: key quoting and version-dependent emit. use oxc_allocator::Allocator; -use oxc_angular_compiler::linker::link; +use oxc_angular_compiler::AngularVersion; +use oxc_angular_compiler::linker::{LinkOptions, link, link_with_options}; /// Helper to build a ɵɵngDeclareDirective source with a given inputs block. fn make_directive_source(inputs_block: &str) -> String { @@ -252,3 +253,86 @@ MyService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0 assert!(result.linked); assert!(result.code.contains("ɵɵinvalidFactory"), "deps not invalid:\n{}", result.code); } + +/// A component with `[src]` bindings on `img`, `video` and `a` (control), plus a safe read. +const SRC_BINDINGS_COMPONENT: &str = r#" +import * as i0 from "@angular/core"; +class Media { +} +Media.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.0", type: Media, isStandalone: true, selector: "x-media", ngImport: i0, template: '{{ item?.label }}' }); +"#; + +/// A directive binding `src` on its `img` host element. +const SRC_HOST_DIRECTIVE: &str = r#" +import * as i0 from "@angular/core"; +class ImgSrc { +} +ImgSrc.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", type: ImgSrc, isStandalone: true, selector: "img[imgSrc]", host: { properties: { "src": "url" } }, ngImport: i0 }); +"#; + +fn link_for_version(code: &str, version: Option) -> String { + let allocator = Allocator::default(); + let options = LinkOptions { angular_version: version }; + let result = link_with_options(&allocator, code, "test.mjs", &options); + assert!(result.linked); + result.code +} + +/// Angular before 22.2.2 sanitizes `img|src` and `video|src` as URLs, and its runtime relies +/// on that sanitizer to unwrap `SafeUrl`s. The linker must honor the application's version. +#[test] +fn test_link_sanitizes_img_video_src_before_22_2_2() { + for version in [AngularVersion::new(21, 2, 0), AngularVersion::new(22, 2, 1)] { + let code = link_for_version(SRC_BINDINGS_COMPONENT, Some(version)); + assert_eq!( + code.matches(r#"ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#).count(), + 2, + "{version:?}: img and video src must be sanitized:\n{code}" + ); + assert!(code.contains(r#"ɵɵproperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); + } +} + +/// Angular 22.2.2 removed `img|src` and `video|src` from the URL security context +/// (angular/angular@439ff1bdf4). An unset version keeps assuming the latest Angular. +#[test] +fn test_link_does_not_sanitize_img_video_src_from_22_2_2() { + for version in [Some(AngularVersion::new(22, 2, 2)), None] { + let code = link_for_version(SRC_BINDINGS_COMPONENT, version); + assert_eq!( + code.matches(r#"ɵɵproperty("src",ctx.url)"#).count(), + 2, + "{version:?}: img and video src must not be sanitized:\n{code}" + ); + assert!(code.contains(r#"ɵɵproperty("href",ctx.url,i0.ɵɵsanitizeUrl)"#), "{code}"); + } + + let allocator = Allocator::default(); + let default_link = link(&allocator, SRC_BINDINGS_COMPONENT, "test.mjs").code; + assert_eq!(default_link, link_for_version(SRC_BINDINGS_COMPONENT, None)); +} + +#[test] +fn test_link_host_src_binding_respects_angular_version() { + let before = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 1))); + assert!( + before.contains(r#"ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#), + "host img src must be sanitized before 22.2.2:\n{before}" + ); + let after = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 2))); + assert!( + after.contains(r#"ɵɵdomProperty("src",ctx.url)"#), + "host img src must not be sanitized from 22.2.2:\n{after}" + ); +} + +/// Angular's linker derives `?.` semantics from the declaration's own `version`, so passing the +/// application's version must not switch linked templates to native optional chaining. +#[test] +fn test_link_angular_version_keeps_legacy_safe_navigation() { + for version in [Some(AngularVersion::new(22, 2, 1)), None] { + let code = link_for_version(SRC_BINDINGS_COMPONENT, version); + assert!(code.contains("== null)? null: ctx.item.label"), "{version:?}:\n{code}"); + assert!(!code.contains("ctx.item?.label"), "{version:?}:\n{code}"); + } +} diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 0e14b81ce..47f730c53 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -197,6 +197,7 @@ interface PluginOptions { // Project configuration tsconfig?: string workspaceRoot?: string + // Application's Angular version, used for app code and linked libraries (default: latest) angularVersion?: { major: number; minor: number; patch: number } // Features diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 09d52c6df..4f1b6fda8 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -648,7 +648,11 @@ export interface InjectorNapiCompileResult { * This is the async version of `linkAngularPackageSync`. Use this when * linking packages in a non-blocking context. */ -export declare function linkAngularPackage(code: string, filename: string): Promise +export declare function linkAngularPackage( + code: string, + filename: string, + options?: LinkOptions | undefined | null, +): Promise /** * Link Angular partial declarations in a JavaScript file (sync). @@ -664,13 +668,30 @@ export declare function linkAngularPackage(code: string, filename: string): Prom * * * `code` - The JavaScript source code to link * * `filename` - The filename (for source maps and error messages) + * * `options` - Linker options, such as the application's Angular version * * # Returns * * A `LinkResult` containing the linked code. If no partial declarations * were found, the original code is returned with `linked: false`. */ -export declare function linkAngularPackageSync(code: string, filename: string): LinkResult +export declare function linkAngularPackageSync( + code: string, + filename: string, + options?: LinkOptions | undefined | null, +): LinkResult + +/** Options for linking Angular partial declarations. */ +export interface LinkOptions { + /** + * Angular version of the consuming application. + * + * Selects version-dependent emit such as the DOM security schema, like + * Angular's own linker does with the application's `@angular/compiler`. + * When not set, assumes the latest Angular version. + */ + angularVersion?: AngularVersion +} /** Result of linking Angular partial declarations. */ export interface LinkResult { diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 7cfd2e23a..95a7ed77d 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -2936,25 +2936,46 @@ pub struct LinkResult { /// /// * `code` - The JavaScript source code to link /// * `filename` - The filename (for source maps and error messages) +/// * `options` - Linker options, such as the application's Angular version /// /// # Returns /// /// A `LinkResult` containing the linked code. If no partial declarations /// were found, the original code is returned with `linked: false`. #[napi] -pub fn link_angular_package_sync(code: String, filename: String) -> LinkResult { - use oxc_angular_compiler::linker::link; +pub fn link_angular_package_sync( + code: String, + filename: String, + options: Option, +) -> LinkResult { + use oxc_angular_compiler::linker::link_with_options; let allocator = Allocator::default(); - let result = link(&allocator, &code, &filename); + let options = oxc_angular_compiler::linker::LinkOptions { + angular_version: options.and_then(|o| o.angular_version).map(Into::into), + }; + let result = link_with_options(&allocator, &code, &filename, &options); LinkResult { code: result.code, map: result.map, linked: result.linked } } +/// Options for linking Angular partial declarations. +#[derive(Default)] +#[napi(object)] +pub struct LinkOptions { + /// Angular version of the consuming application. + /// + /// Selects version-dependent emit such as the DOM security schema, like + /// Angular's own linker does with the application's `@angular/compiler`. + /// When not set, assumes the latest Angular version. + pub angular_version: Option, +} + /// Async task for Angular linking. pub struct LinkAngularPackageTask { code: String, filename: String, + options: Option, } #[napi] @@ -2966,6 +2987,7 @@ impl Task for LinkAngularPackageTask { Ok(link_angular_package_sync( std::mem::take(&mut self.code), std::mem::take(&mut self.filename), + self.options.take(), )) } @@ -2979,6 +3001,10 @@ impl Task for LinkAngularPackageTask { /// This is the async version of `linkAngularPackageSync`. Use this when /// linking packages in a non-blocking context. #[napi] -pub fn link_angular_package(code: String, filename: String) -> AsyncTask { - AsyncTask::new(LinkAngularPackageTask { code, filename }) +pub fn link_angular_package( + code: String, + filename: String, + options: Option, +) -> AsyncTask { + AsyncTask::new(LinkAngularPackageTask { code, filename, options }) } diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index 8da82c5b7..6ce50d12c 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -263,3 +263,67 @@ describe('linker: namespaced forwardRef', () => { expect(result.code).not.toMatch(/forwardRef\(\(\)\s*=>\s*\w+\)\.ɵfac/) }) }) + +/** + * Angular 22.2.2 dropped `img|src` / `video|src` from the URL security context and added a + * runtime shim to compensate. Older runtimes still need `ɵɵsanitizeUrl` on those bindings to + * unwrap `SafeUrl`s, so the linker must follow the application's `angularVersion`. + */ +const IMG_SRC_COMPONENT = ` +import * as i0 from '@angular/core'; + +class Preview { + static ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "14.0.0", + version: "21.0.0", + ngImport: i0, + type: Preview, + isStandalone: true, + selector: "x-preview", + template: '' + }); +} +export { Preview }; +` + +describe('linker: angularVersion', () => { + const id = 'node_modules/x-preview/fesm2022/x-preview.mjs' + + it('sanitizes img src when linking for Angular before 22.2.2', () => { + const result = linkAngularPackageSync(IMG_SRC_COMPONENT, id, { + angularVersion: { major: 22, minor: 2, patch: 1 }, + }) + + expect(result.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + }) + + it('assumes the latest Angular when no version is given', () => { + expect(linkAngularPackageSync(IMG_SRC_COMPONENT, id).code).toContain( + 'ɵɵproperty("src",ctx.url)', + ) + }) + + it('forwards the plugin angularVersion through the Vite transform hook', async () => { + const plugin = angularLinkerPlugin({ + angularVersion: { major: 22, minor: 2, patch: 1 }, + }) as any + const result = await plugin.transform.handler.call({}, IMG_SRC_COMPONENT, id) + + expect(result?.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + }) + + it('forwards the plugin angularVersion through the dependency optimizer', async () => { + const plugin = angularLinkerPlugin({ + angularVersion: { major: 22, minor: 2, patch: 1 }, + }) as any + plugin.configResolved({ optimizeDeps: {} }) + const [optimizerPlugin] = plugin.config({}, { command: 'serve' }).optimizeDeps.rolldownOptions + .plugins + const code = await optimizerPlugin.load.handler.call( + { fs: { readFile: async () => IMG_SRC_COMPONENT } }, + id, + ) + + expect(code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + }) +}) diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index bddd57549..7e00aa65a 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -1,4 +1,4 @@ -import { linkAngularPackage } from '#binding' +import { linkAngularPackage, type AngularVersion } from '#binding' import type { Plugin, ResolvedConfig } from 'vite' /** @@ -88,8 +88,9 @@ function createExcludeFilter(exclude: readonly string[]): (id: string) => boolea async function linkCode( code: string, id: string, + angularVersion: AngularVersion | undefined, ): Promise<{ code: string; map: string | null; linked: boolean }> { - const result = await linkAngularPackage(code, id) + const result = await linkAngularPackage(code, id, { angularVersion }) return { code: result.linked ? result.code : code, map: result.map ?? null, @@ -97,7 +98,12 @@ async function linkCode( } } -export function angularLinkerPlugin(): Plugin { +export interface AngularLinkerPluginOptions { + /** Angular version of the application, forwarded to the linker. Assumes latest when unset. */ + angularVersion?: AngularVersion +} + +export function angularLinkerPlugin({ angularVersion }: AngularLinkerPluginOptions = {}): Plugin { let optimizeDepsExclude: readonly string[] = [] return { @@ -144,7 +150,7 @@ export function angularLinkerPlugin(): Plugin { return } - const result = await linkCode(code, id) + const result = await linkCode(code, id, angularVersion) if (!result.linked) { return @@ -183,7 +189,7 @@ export function angularLinkerPlugin(): Plugin { return } - const result = await linkCode(code, id) + const result = await linkCode(code, id, angularVersion) if (!result.linked) { return diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 562a15385..8bc76e51c 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -1544,7 +1544,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { angularPlugin(), stylesPlugin(), dtsPlugin(), - angularLinkerPlugin(), + angularLinkerPlugin({ angularVersion: pluginOptions.angularVersion }), pluginOptions.jit && jitPlugin({ inlineStylesExtension: pluginOptions.inlineStylesExtension, From 5d395769b5959747d4a7fcb6baa8c3786342de14 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 17:19:50 +0800 Subject: [PATCH 2/4] fix(vite): re-link prebundled deps when `angularVersion` changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vite: name the dep optimizer plugin `angular-linker@` when `angularVersion` is set (unset keeps `angular-linker`). Vite hashes optimizer plugin names, not their options, so a version change kept deps linked for the old version until `--force`. - linker tests: component host path (`ɵɵhostProperty` at 19.2.0, `ɵɵdomProperty` at 22.2.1, legacy `?.`) and template ingest (`@if` svg root tag `:svg:rect` at 22.2.1, `rect` at 22.2.2), matching Angular's linker. - Reword the directive host `img src` test: it checks OXC's host schema switch; Angular emits `ɵɵsanitizeUrlOrResourceUrl` there (d06e3748b7, not ported yet). - napi tests: optimizer plugin name per version; `angular()` forwards `angularVersion` to the linker plugin. - Comments: legacy `?.` pin, optional consumer version, and the `PluginOptions.angularVersion` JSDoc; `RustLinkOptions` idiom in napi. Co-Authored-By: Claude Opus 5.5 --- .../src/component/transform.rs | 10 ++-- crates/oxc_angular_compiler/src/linker/mod.rs | 14 +++-- .../oxc_angular_compiler/tests/linker_test.rs | 57 ++++++++++++++++++- napi/angular-compiler/src/lib.rs | 6 +- napi/angular-compiler/test/linker.test.ts | 23 ++++++++ .../vite-plugin/angular-linker-plugin.ts | 14 ++++- napi/angular-compiler/vite-plugin/index.ts | 2 + 7 files changed, 110 insertions(+), 16 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 30dfb3269..4ca4f9736 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -6307,8 +6307,9 @@ pub fn compile_host_bindings_for_linker( selector, pool_starting_index, angular_version, - // `?.` semantics follow the declaration's own `version` in Angular's linker, not - // the application's, so `angular_version` must not switch them: keep legacy. + // Angular's linker uses native `?.` for declarations at version 22+ (and the + // placeholder), which is not modeled here yet: keep legacy, and never let the + // application's `angular_version` switch it. Some(true), )?; @@ -6434,8 +6435,9 @@ pub fn compile_template_for_linker<'a>( all_deferrable_deps_fn: None, pool_starting_index: 0, angular_version, - // `?.` semantics follow the declaration's own `version` in Angular's linker, not - // the application's, so `angular_version` must not switch them: keep legacy. + // Angular's linker uses native `?.` for declarations at version 22+ (and the + // placeholder), which is not modeled here yet: keep legacy, and never let the + // application's `angular_version` switch it. legacy_optional_chaining: Some(true), }; diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 9478cb273..514401547 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -2146,8 +2146,9 @@ fn link_component( } else if cd.contains("OnPush") { // Emitted explicitly rather than left to the runtime default. The TS // compiler omits OnPush here, but it ships with the runtime it targets; - // this linker does not know the consumer's version and supports back to - // v19. Pre-v22 runtimes compute `onPush = changeDetection === OnPush`, + // this linker may not know the consumer's version + // (`LinkOptions.angular_version` is optional) and supports back to v19. + // Pre-v22 runtimes compute `onPush = changeDetection === OnPush`, // so an absent field reads as Default there and the component would // silently lose OnPush. `0` is correct on both. parts.push("changeDetection: 0".to_string()); @@ -2855,10 +2856,11 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: " let result = link(&allocator, code, "test.mjs"); assert!(result.linked); // Emitted explicitly, even though the TS compiler omits OnPush. That - // compiler ships with the runtime it targets; this linker does not know - // the consumer's version and supports back to v19, where the runtime - // computes `onPush = changeDetection === OnPush` and so reads an absent - // field as Default. Omitting it would silently drop OnPush there. + // compiler ships with the runtime it targets; this linker may not know + // the consumer's version (`LinkOptions.angular_version` is optional) and + // supports back to v19, where the runtime computes + // `onPush = changeDetection === OnPush` and so reads an absent field as + // Default. Omitting it would silently drop OnPush there. assert!( result.code.contains("changeDetection: 0"), "ChangeDetectionStrategy.OnPush should be 0, got:\n{}", diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 6dd142bd8..a61cf4d61 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -270,6 +270,22 @@ class ImgSrc { ImgSrc.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.0", type: ImgSrc, isStandalone: true, selector: "img[imgSrc]", host: { properties: { "src": "url" } }, ngImport: i0 }); "#; +/// A component whose host binding and template both read `item?.label`. +const SAFE_NAV_HOST_COMPONENT: &str = r#" +import * as i0 from "@angular/core"; +class Item { +} +Item.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.0", type: Item, isStandalone: true, selector: "x-item", host: { properties: { "title": "item?.label" } }, ngImport: i0, template: '{{ item?.label }}' }); +"#; + +/// A component whose `@if` block has a single namespaced root element. +const SVG_BLOCK_ROOT_COMPONENT: &str = r#" +import * as i0 from "@angular/core"; +class Shape { +} +Shape.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.0.0", type: Shape, isStandalone: true, selector: "x-shape", ngImport: i0, template: '@if (show) { }' }); +"#; + fn link_for_version(code: &str, version: Option) -> String { let allocator = Allocator::default(); let options = LinkOptions { angular_version: version }; @@ -312,17 +328,54 @@ fn test_link_does_not_sanitize_img_video_src_from_22_2_2() { assert_eq!(default_link, link_for_version(SRC_BINDINGS_COMPONENT, None)); } +/// Checks that the application's version reaches the directive host path and switches OXC's +/// host security schema at 22.2.2. +/// Angular itself emits `ɵɵsanitizeUrlOrResourceUrl` here (concrete host security contexts, +/// angular/angular@d06e3748b7, not ported yet), so these expectations change with that port. #[test] fn test_link_host_src_binding_respects_angular_version() { let before = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 1))); assert!( before.contains(r#"ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#), - "host img src must be sanitized before 22.2.2:\n{before}" + "22.2.1 must reach the directive host path and keep `img|src` in OXC's URL schema:\n{before}" ); let after = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 2))); assert!( after.contains(r#"ɵɵdomProperty("src",ctx.url)"#), - "host img src must not be sanitized from 22.2.2:\n{after}" + "22.2.2 must reach the directive host path and drop `img|src` from OXC's URL schema:\n{after}" + ); +} + +/// Component host bindings follow the application's version (`ɵɵhostProperty` before 20, +/// `ɵɵdomProperty` from 20) but keep legacy `?.`, matching Angular's linker on a 21.0.0 +/// declaration with `@angular/compiler` 19.2.0 and 22.2.1. +#[test] +fn test_link_component_host_respects_angular_version() { + let v19 = link_for_version(SAFE_NAV_HOST_COMPONENT, Some(AngularVersion::new(19, 2, 0))); + assert!( + v19.contains(r#"ɵɵhostProperty("title",((ctx.item == null)? null: ctx.item.label))"#), + "19.2.0: component host must use ɵɵhostProperty with legacy `?.`:\n{v19}" + ); + let v22 = link_for_version(SAFE_NAV_HOST_COMPONENT, Some(AngularVersion::new(22, 2, 1))); + assert!( + v22.contains(r#"ɵɵdomProperty("title",((ctx.item == null)? null: ctx.item.label))"#), + "22.2.1: component host must use ɵɵdomProperty with legacy `?.`:\n{v22}" + ); +} + +/// The application's version reaches template ingest: Angular 22.2.2 strips the namespace +/// from a control-flow block's root tag (angular/angular@0244a1cb3), earlier versions keep it. +#[test] +fn test_link_block_root_tag_respects_angular_version() { + let before = link_for_version(SVG_BLOCK_ROOT_COMPONENT, Some(AngularVersion::new(22, 2, 1))); + assert!( + before.contains(r#"ɵɵconditionalCreate(0,Shape_Conditional_0_Template,1,0,":svg:rect")"#), + "22.2.1: block root tag must keep its namespace:\n{before}" + ); + let after = link_for_version(SVG_BLOCK_ROOT_COMPONENT, Some(AngularVersion::new(22, 2, 2))); + assert!( + after.contains(r#"ɵɵconditionalCreate(0,Shape_Conditional_0_Template,1,0,"rect")"#), + "22.2.2: block root tag must drop its namespace:\n{after}" ); } diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 95a7ed77d..8e838932f 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -2948,13 +2948,13 @@ pub fn link_angular_package_sync( filename: String, options: Option, ) -> LinkResult { - use oxc_angular_compiler::linker::link_with_options; + use oxc_angular_compiler::linker::{LinkOptions as RustLinkOptions, link_with_options}; let allocator = Allocator::default(); - let options = oxc_angular_compiler::linker::LinkOptions { + let rust_options = RustLinkOptions { angular_version: options.and_then(|o| o.angular_version).map(Into::into), }; - let result = link_with_options(&allocator, &code, &filename, &options); + let result = link_with_options(&allocator, &code, &filename, &rust_options); LinkResult { code: result.code, map: result.map, linked: result.linked } } diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index 6ce50d12c..baf09295d 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { linkAngularPackageSync } from '../index.js' import { angularLinkerPlugin } from '../vite-plugin/angular-linker-plugin.js' +import { angular } from '../vite-plugin/index.js' /** * Minimal Angular partial declaration fixtures that simulate the structure @@ -326,4 +327,26 @@ describe('linker: angularVersion', () => { expect(code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') }) + + it('keys the dependency optimizer cache on angularVersion', () => { + // Vite hashes optimizer plugin names, not their options, into the deps cache key. + const optimizerPluginName = (plugin: any) => + plugin.config({}, { command: 'serve' }).optimizeDeps.rolldownOptions.plugins[0].name + const unset = optimizerPluginName(angularLinkerPlugin()) + const pinned = optimizerPluginName( + angularLinkerPlugin({ angularVersion: { major: 22, minor: 2, patch: 1 } }), + ) + + expect(unset).toBe('angular-linker') + expect(pinned).not.toBe(unset) + }) + + it('forwards angular() angularVersion to the linker plugin', async () => { + const plugin = angular({ angularVersion: { major: 22, minor: 2, patch: 1 } }).find( + (candidate) => candidate.name === '@oxc-angular/vite-linker', + ) as any + const result = await plugin.transform.handler.call({}, IMG_SRC_COMPONENT, id) + + expect(result?.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + }) }) diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index 7e00aa65a..888f8032b 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -103,6 +103,18 @@ export interface AngularLinkerPluginOptions { angularVersion?: AngularVersion } +/** + * Name of the dependency optimizer plugin. Vite hashes the names of + * `optimizeDeps.rolldownOptions.plugins`, not their options, into the optimizer + * cache key, so the version is part of the name: changing `angularVersion` + * re-links prebundled deps instead of reusing ones linked for another version. + */ +function optimizerPluginName(angularVersion: AngularVersion | undefined): string { + if (!angularVersion) return 'angular-linker' + const { major, minor, patch, prerelease } = angularVersion + return `angular-linker@${major}.${minor}.${patch}${prerelease ? `-${prerelease}` : ''}` +} + export function angularLinkerPlugin({ angularVersion }: AngularLinkerPluginOptions = {}): Plugin { let optimizeDepsExclude: readonly string[] = [] @@ -127,7 +139,7 @@ export function angularLinkerPlugin({ angularVersion }: AngularLinkerPluginOptio }, plugins: [ { - name: 'angular-linker', + name: optimizerPluginName(angularVersion), load: { filter: { id: /\.[cm]?js$/, diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 8bc76e51c..3dfb9a0e0 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -97,6 +97,8 @@ export interface PluginOptions { * uses `ɵɵtemplate` for `@if`/`@switch` blocks, while Angular 20+ uses * `ɵɵconditionalCreate`/`ɵɵconditionalBranchCreate`. * + * Also used when linking partially compiled libraries (`ɵɵngDeclare*`). + * * When not set, assumes latest Angular version (v20+ behavior). * * @example From 83e1e98555a62e93f487be5147595f8aefe3c7ae Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 17:36:21 +0800 Subject: [PATCH 3/4] test(vite): pin the exact optimizer plugin name per `angularVersion` Assert the exact name for 22.2.1, 22.2.2 and a prerelease, so a name that drops the patch or prerelease (22.2.1 and 22.2.2 sharing one deps cache) fails. Key an empty prerelease separately: the linker does not treat it as a stable release. The `angular()` wiring test also checks 22.2.2. Co-Authored-By: Claude Opus 5.5 --- napi/angular-compiler/test/linker.test.ts | 37 ++++++++++++------- .../vite-plugin/angular-linker-plugin.ts | 2 +- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index baf09295d..cdc8c73c3 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { linkAngularPackageSync } from '../index.js' +import { linkAngularPackageSync, type AngularVersion } from '../index.js' import { angularLinkerPlugin } from '../vite-plugin/angular-linker-plugin.js' import { angular } from '../vite-plugin/index.js' @@ -332,21 +332,32 @@ describe('linker: angularVersion', () => { // Vite hashes optimizer plugin names, not their options, into the deps cache key. const optimizerPluginName = (plugin: any) => plugin.config({}, { command: 'serve' }).optimizeDeps.rolldownOptions.plugins[0].name - const unset = optimizerPluginName(angularLinkerPlugin()) - const pinned = optimizerPluginName( - angularLinkerPlugin({ angularVersion: { major: 22, minor: 2, patch: 1 } }), + const nameFor = (angularVersion?: AngularVersion) => + optimizerPluginName(angularLinkerPlugin({ angularVersion })) + + expect(nameFor()).toBe('angular-linker') + expect(nameFor({ major: 22, minor: 2, patch: 1 })).toBe('angular-linker@22.2.1') + expect(nameFor({ major: 22, minor: 2, patch: 2 })).toBe('angular-linker@22.2.2') + expect(nameFor({ major: 23, minor: 0, patch: 0, prerelease: 'next.1' })).toBe( + 'angular-linker@23.0.0-next.1', + ) + // An empty prerelease is not a stable release for the linker, so it needs its own key. + expect(nameFor({ major: 22, minor: 3, patch: 0, prerelease: '' })).toBe( + 'angular-linker@22.3.0-', ) - - expect(unset).toBe('angular-linker') - expect(pinned).not.toBe(unset) }) it('forwards angular() angularVersion to the linker plugin', async () => { - const plugin = angular({ angularVersion: { major: 22, minor: 2, patch: 1 } }).find( - (candidate) => candidate.name === '@oxc-angular/vite-linker', - ) as any - const result = await plugin.transform.handler.call({}, IMG_SRC_COMPONENT, id) - - expect(result?.code).toContain('ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)') + const link = async (angularVersion: AngularVersion) => { + const plugin = angular({ angularVersion }).find( + (candidate) => candidate.name === '@oxc-angular/vite-linker', + ) as any + return (await plugin.transform.handler.call({}, IMG_SRC_COMPONENT, id))?.code + } + + expect(await link({ major: 22, minor: 2, patch: 1 })).toContain( + 'ɵɵproperty("src",ctx.url,i0.ɵɵsanitizeUrl)', + ) + expect(await link({ major: 22, minor: 2, patch: 2 })).toContain('ɵɵproperty("src",ctx.url)') }) }) diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index 888f8032b..64ad51a50 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -112,7 +112,7 @@ export interface AngularLinkerPluginOptions { function optimizerPluginName(angularVersion: AngularVersion | undefined): string { if (!angularVersion) return 'angular-linker' const { major, minor, patch, prerelease } = angularVersion - return `angular-linker@${major}.${minor}.${patch}${prerelease ? `-${prerelease}` : ''}` + return `angular-linker@${major}.${minor}.${patch}${prerelease != null ? `-${prerelease}` : ''}` } export function angularLinkerPlugin({ angularVersion }: AngularLinkerPluginOptions = {}): Plugin { From d2bbca709310cb046a58cfd7d171c58dff3d7b2c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 17:46:25 +0800 Subject: [PATCH 4/4] test(linker): do not pin the host `src` output that differs from Angular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directive host `src` test required no sanitizer at 22.2.2, but Angular's linker emits `ɵɵsanitizeUrlOrResourceUrl` there (angular/angular@d06e3748b7, not ported yet). Assert only that 22.2.1 sanitizes. That still fails when the directive host path drops the version. Co-Authored-By: Claude Opus 5.5 --- .../oxc_angular_compiler/tests/linker_test.rs | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index a61cf4d61..7edef244e 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -328,21 +328,17 @@ fn test_link_does_not_sanitize_img_video_src_from_22_2_2() { assert_eq!(default_link, link_for_version(SRC_BINDINGS_COMPONENT, None)); } -/// Checks that the application's version reaches the directive host path and switches OXC's -/// host security schema at 22.2.2. -/// Angular itself emits `ɵɵsanitizeUrlOrResourceUrl` here (concrete host security contexts, -/// angular/angular@d06e3748b7, not ported yet), so these expectations change with that port. +/// Checks that the application's version reaches the directive host path: before 22.2.2, +/// `img|src` is in the URL schema, so the host binding must be sanitized. +/// Only "sanitized" is asserted, not which sanitizer, and 22.2.2 is not asserted at all: +/// Angular emits `ɵɵsanitizeUrlOrResourceUrl` here for both versions (concrete host security +/// contexts, angular/angular@d06e3748b7), which OXC does not port yet. #[test] fn test_link_host_src_binding_respects_angular_version() { - let before = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 1))); + let code = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 1))); assert!( - before.contains(r#"ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitizeUrl)"#), - "22.2.1 must reach the directive host path and keep `img|src` in OXC's URL schema:\n{before}" - ); - let after = link_for_version(SRC_HOST_DIRECTIVE, Some(AngularVersion::new(22, 2, 2))); - assert!( - after.contains(r#"ɵɵdomProperty("src",ctx.url)"#), - "22.2.2 must reach the directive host path and drop `img|src` from OXC's URL schema:\n{after}" + code.contains(r#"ɵɵdomProperty("src",ctx.url,i0.ɵɵsanitize"#), + "22.2.1 must reach the directive host path and sanitize `img|src`:\n{code}" ); }