From ba3f3fe6366b2a024f81f04aeeaa7971badaa83d Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Sun, 25 Oct 2026 09:22:10 +0100 Subject: [PATCH] fix(component): apply the project-wide preserveWhitespaces option `TransformOptions.preserveWhitespaces` is now the default for components whose decorator does not set it, as in ngtsc's `parseTemplateDeclaration`. The Vite plugin gets a `preserveWhitespaces` option, used for HMR updates too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/decorator.rs | 12 +-- .../src/component/metadata.rs | 8 +- .../src/component/transform.rs | 14 +++- .../src/partial/component.rs | 2 +- .../tests/hmr_component_update_test.rs | 9 +++ .../tests/preserve_whitespaces_test.rs | 79 +++++++++++++++++++ napi/angular-compiler/README.md | 5 ++ napi/angular-compiler/index.d.ts | 3 +- napi/angular-compiler/src/lib.rs | 5 +- napi/angular-compiler/vite-plugin/index.ts | 9 +++ 10 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/preserve_whitespaces_test.rs diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 2a617d1ea..7de9bc3a4 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -207,7 +207,7 @@ pub fn extract_component_metadata<'a>( } "preserveWhitespaces" => { metadata.preserve_whitespaces = - extract_boolean_value(&prop.value).unwrap_or(false); + Some(extract_boolean_value(&prop.value).unwrap_or(false)); } "animations" => { // Extract animations expression as full OutputExpression @@ -1667,7 +1667,7 @@ mod tests { class TestComponent {} "#; assert_metadata(code, |meta| { - assert!(meta.preserve_whitespaces); + assert_eq!(meta.preserve_whitespaces, Some(true)); }); } @@ -1683,12 +1683,12 @@ mod tests { class TestComponent {} "#; assert_metadata(code, |meta| { - assert!(!meta.preserve_whitespaces); + assert_eq!(meta.preserve_whitespaces, Some(false)); }); } #[test] - fn test_preserve_whitespaces_defaults_to_false() { + fn test_preserve_whitespaces_unset() { let code = r#" import {Component} from '@angular/core'; @Component({ @@ -1698,7 +1698,7 @@ mod tests { class TestComponent {} "#; assert_metadata(code, |meta| { - assert!(!meta.preserve_whitespaces); + assert_eq!(meta.preserve_whitespaces, None); }); } @@ -1831,7 +1831,7 @@ mod tests { assert_eq!(meta.imports.len(), 1); assert_eq!(meta.export_as.len(), 1); assert_eq!(meta.export_as[0].as_str(), "complete"); - assert!(meta.preserve_whitespaces); + assert_eq!(meta.preserve_whitespaces, Some(true)); assert_eq!(meta.schemas.len(), 1); }); } diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index b97bc38d8..2930f93ac 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -434,8 +434,10 @@ pub struct ComponentMetadata<'a> { /// which is split into an array and emitted as `exportAs: ["foo", "bar"]`. pub export_as: Vec<'a, Ident<'a>>, - /// Whether to preserve whitespace in templates. - pub preserve_whitespaces: bool, + /// The decorator's `preserveWhitespaces`, or `None` when it does not set + /// one and the project-wide `TransformOptions::preserve_whitespaces` + /// applies. + pub preserve_whitespaces: Option, /// Constructor parameter dependencies for DI. /// @@ -853,7 +855,7 @@ impl<'a> ComponentMetadata<'a> { host: None, imports: Vec::new_in(&allocator), export_as: Vec::new_in(&allocator), - preserve_whitespaces: false, + preserve_whitespaces: None, constructor_deps: None, inputs: Vec::new_in(&allocator), outputs: Vec::new_in(&allocator), diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 9bca18f5e..bc4eb336d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -137,7 +137,10 @@ pub struct TransformOptions { /// Override the change detection strategy. pub change_detection: Option, - /// Override the preserve whitespaces setting. + /// Project-wide `preserveWhitespaces` (`angularCompilerOptions`): the + /// default for components whose decorator does not set it. The + /// template-only compiles (`compile_template_to_js_with_options`, + /// `compile_template_for_hmr`) use it as the template's value. pub preserve_whitespaces: Option, /// Host bindings metadata for the component. @@ -5393,11 +5396,18 @@ fn compile_component_full<'a>( let mut diagnostics = Vec::new(); + // The decorator's `preserveWhitespaces` wins over the project-wide option + // (`parseTemplateDeclaration` in ngtsc's component resources.ts). Written + // back so the partial declaration carries the resolved value too. + let preserve_whitespaces = + metadata.preserve_whitespaces.or(options.preserve_whitespaces).unwrap_or(false); + metadata.preserve_whitespaces = Some(preserve_whitespaces); + // Stage 1: Parse HTML // Build parse options from component metadata // Angular always forces tokenizeExpansionForms: true in parseTemplate() let parse_options = ParseTemplateOptions { - preserve_whitespaces: metadata.preserve_whitespaces, + preserve_whitespaces, // Enable modern syntax features (block syntax, let declarations) enable_block_syntax: true, enable_let_syntax: true, diff --git a/crates/oxc_angular_compiler/src/partial/component.rs b/crates/oxc_angular_compiler/src/partial/component.rs index a703e069d..b5ab0e17d 100644 --- a/crates/oxc_angular_compiler/src/partial/component.rs +++ b/crates/oxc_angular_compiler/src/partial/component.rs @@ -337,7 +337,7 @@ pub fn compile_declare_component_from_metadata<'a>( )); } - if meta.preserve_whitespaces { + if meta.preserve_whitespaces == Some(true) { entries.push(LiteralMapEntry::new( Ident::from("preserveWhitespaces"), bool_lit(allocator, true), diff --git a/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs b/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs index 66c95575a..e7469fc29 100644 --- a/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs +++ b/crates/oxc_angular_compiler/tests/hmr_component_update_test.rs @@ -75,6 +75,15 @@ fn keeps_whitespace_the_decorator_preserves() { assert!(update.template_js.contains(r#"ɵɵtext(1,"\n ")"#), "{}", update.template_js); } +#[test] +fn keeps_whitespace_the_project_option_preserves() { + let source = component("", "
\n {{ name }} \n
"); + let options = hmr(TransformOptions { preserve_whitespaces: Some(true), ..Default::default() }); + let update = update(&source, &options, None); + assert_template_matches_transform(&update, &transform(&source, &options, None)); + assert!(update.template_js.contains(r#"ɵɵtext(1,"\n ")"#), "{}", update.template_js); +} + #[test] fn names_i18n_messages_after_the_file() { let source = component("", "

Hello {{ name }}

"); diff --git a/crates/oxc_angular_compiler/tests/preserve_whitespaces_test.rs b/crates/oxc_angular_compiler/tests/preserve_whitespaces_test.rs new file mode 100644 index 000000000..b7c851440 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/preserve_whitespaces_test.rs @@ -0,0 +1,79 @@ +//! The project-wide `preserveWhitespaces` option (`angularCompilerOptions`) is +//! the default for components that do not set their own, as in ngtsc's +//! `parseTemplateDeclaration` (annotations/component/src/resources.ts). +//! +//! Ported from Angular's `ngtsc_spec.ts` `preserveWhitespaces` cases. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{CompilationMode, TransformOptions, transform_angular_file}; + +fn compile(decorator_option: &str, options: &TransformOptions) -> String { + let source = format!( + "import {{Component}} from '@angular/core'; +@Component({{ + selector: 'test',{decorator_option} + template: ` +
+ Template with whitespaces +
+ ` +}}) +export class FooCmp {{}} +" + ); + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "test.ts", &source, Some(options), None); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + result.code +} + +const COLLAPSED: &str = r#"ɵɵtext(1," Template with whitespaces ")"#; +const PRESERVED: &str = r#"ɵɵtext(2,"\n Template with whitespaces\n ")"#; + +fn squash(code: &str) -> String { + code.replace(", ", ",") +} + +#[test] +fn defaults_to_collapsing_whitespace() { + let code = compile("", &TransformOptions::default()); + assert!(squash(&code).contains(COLLAPSED), "{code}"); +} + +#[test] +fn component_false_collapses_whitespace() { + let code = compile("\n preserveWhitespaces: false,", &TransformOptions::default()); + assert!(squash(&code).contains(COLLAPSED), "{code}"); +} + +#[test] +fn takes_the_preserve_whitespaces_option_into_account() { + let options = TransformOptions { preserve_whitespaces: Some(true), ..Default::default() }; + let code = compile("", &options); + assert!(squash(&code).contains(PRESERVED), "{code}"); +} + +#[test] +fn component_preserve_whitespaces_overrides_the_option() { + let options = TransformOptions { preserve_whitespaces: Some(true), ..Default::default() }; + let code = compile("\n preserveWhitespaces: false,", &options); + assert!(squash(&code).contains(COLLAPSED), "{code}"); + + let options = TransformOptions { preserve_whitespaces: Some(false), ..Default::default() }; + let code = compile("\n preserveWhitespaces: true,", &options); + assert!(squash(&code).contains(PRESERVED), "{code}"); +} + +#[test] +fn partial_declaration_carries_the_resolved_option() { + let options = TransformOptions { + preserve_whitespaces: Some(true), + compilation_mode: CompilationMode::Partial, + ..Default::default() + }; + let code = compile("", &options); + assert!(code.contains("preserveWhitespaces:true"), "{code}"); + + let code = compile("\n preserveWhitespaces: false,", &options); + assert!(!code.contains("preserveWhitespaces:true"), "{code}"); +} diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 5723c6225..d4f77d3f9 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -178,6 +178,7 @@ interface TransformOptions { standalone?: boolean encapsulation?: 'Emulated' | 'None' | 'ShadowDom' changeDetection?: 'Default' | 'OnPush' + // Default for components that do not set their own (project-wide option) preserveWhitespaces?: boolean // Cross-file elision @@ -220,6 +221,10 @@ interface PluginOptions { // Template hook, applied during both the initial build and HMR templateTransform?: (content: string, filePath: string) => string + + // Project-wide default (`angularCompilerOptions.preserveWhitespaces`); + // a component's own `preserveWhitespaces` wins (default: false) + preserveWhitespaces?: boolean } ``` diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 18a416639..e93aae683 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -1077,7 +1077,8 @@ export interface TransformOptions { */ changeDetection?: string /** - * Whether to preserve whitespace in templates. + * Project-wide `preserveWhitespaces` (`angularCompilerOptions`): the + * default for components whose decorator does not set it. * Defaults to false if not specified. */ preserveWhitespaces?: boolean diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 178130ec9..ec283cd16 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -177,7 +177,8 @@ pub struct TransformOptions { /// Defaults to "Default" if not specified. pub change_detection: Option, - /// Whether to preserve whitespace in templates. + /// Project-wide `preserveWhitespaces` (`angularCompilerOptions`): the + /// default for components whose decorator does not set it. /// Defaults to false if not specified. pub preserve_whitespaces: Option, @@ -2178,7 +2179,7 @@ pub fn extract_component_metadata_sync( .join(","), ) }, - preserve_whitespaces: metadata.preserve_whitespaces, + preserve_whitespaces: metadata.preserve_whitespaces.unwrap_or(false), providers, view_providers, animations, diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index e022d15b7..d96076ec3 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -141,6 +141,13 @@ export interface PluginOptions { * ``` */ compilationMode?: 'full' | 'partial' + + /** + * Project-wide `preserveWhitespaces`, like `angularCompilerOptions.preserveWhitespaces` + * in the tsconfig: the default for components whose decorator does not set + * `preserveWhitespaces`. Default: `false`. + */ + preserveWhitespaces?: boolean } // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler @@ -293,6 +300,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, compilationMode: options.compilationMode ?? 'full', + preserveWhitespaces: options.preserveWhitespaces, tsconfig: options.tsconfig, } @@ -552,6 +560,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { minifyComponentStyles: getMinifyComponentStyles(context), emitClassMetadata: pluginOptions.emitClassMetadata, compilationMode: pluginOptions.compilationMode, + preserveWhitespaces: pluginOptions.preserveWhitespaces, // Evaluate decorator metadata imported from other files the way // ngtsc's program-wide checker does (`inputs: INPUTS`, `@Input(OPTS)`, // `templateUrl: TEMPLATE_URL`). Read files land in