From b15cd469dee061196145a6623f00ec65b5792dfb Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 9 Oct 2026 00:29:01 +0100 Subject: [PATCH 1/4] fix(component): evaluate `styles` statically like ngtsc A `styles` entry that was not a string literal, a template literal or a same-file string constant was dropped without an error: a concatenation, a spread, an array constant or an import all compiled to fewer styles than written, or to none. `styles` is now read through the static evaluator, as ngtsc's `parseDirectiveStyles` does. A value that is a string or an array of strings gives those styles. Anything else is ngtsc's error on the `styles` expression (`Failed to resolve @Component.styles to a string or an array of strings`, `Failed to resolve styles at position N to a string`), reported after the selector as ngtsc orders it. An import that cannot be read, which ngtsc resolves with the whole program, is the existing one-file-at-a-time error unless `resolveImportedValues` resolves it. `extractComponentMetadataSync` reads the same value, so the HMR endpoint now serves the styles of an array constant instead of `styles: []`. --- .../src/component/decorator.rs | 13 +- .../src/directive/decorator.rs | 40 +++ .../oxc_angular_compiler/src/directive/mod.rs | 2 +- .../tests/styles_evaluation_test.rs | 252 ++++++++++++++++++ .../styles-static-evaluation.fixture.ts | 55 ++++ .../test/extract-component-metadata.test.ts | 47 +++- .../test/hmr-hot-update.test.ts | 58 ++-- napi/angular-compiler/vite-plugin/index.ts | 4 +- 8 files changed, 428 insertions(+), 43 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/styles_evaluation_test.rs create mode 100644 napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index c797893ff..2411b295f 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -106,13 +106,12 @@ pub fn extract_component_metadata<'a>( crate::directive::extract_string_value(allocator, &prop.value, consts); } "styles" => { - if let Some(styles) = extract_string_array(allocator, &prop.value, consts) { - metadata.styles = styles; - } else if let Some(style) = - crate::directive::extract_string_value(allocator, &prop.value, consts) - { - // Single style string (legacy support) - metadata.styles.push(style); + // Anything that isn't styles is an error `decorator_io_errors` reports. + if let Ok(styles) = crate::directive::evaluate_styles(&prop.value, consts) { + metadata.styles = Vec::from_iter_in( + styles.iter().map(|style| Ident::from(allocator.alloc_str(style))), + &allocator, + ); } } "styleUrls" | "styleUrl" => { diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 3123ac6cc..f7997dab2 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -1436,6 +1436,14 @@ pub fn decorator_io_errors<'a>( let selector = || directive_selector_error(config, decorator_name, &class_name, &evaluator, consts); + // The component handler reads `styles` once the directive metadata is extracted. + let styles = || { + let expr = config + .filter(|_| decorator_name == "Component") + .and_then(|config| config_property(config, "styles", consts))?; + evaluate_styles(expr, consts).err().map(|message| (message, expr.span(), None)) + }; + io.as_ref() .and_then(|io| io.input_error.clone()) .map(at) @@ -1445,6 +1453,7 @@ pub fn decorator_io_errors<'a>( .or_else(member_queries) .or_else(queries) .or_else(selector) + .or_else(styles) .map(|(message, span, related)| { let diagnostic = OxcDiagnostic::error(message).with_label(span); match related { @@ -1490,6 +1499,37 @@ fn directive_selector_error<'a>( None } +/// ngtsc's `parseDirectiveStyles`: the `styles` of a `@Component`, evaluated +/// statically. A string is one style, and every entry of an array must be a +/// string (`isStringArrayOrDie`). Anything else is ngtsc's error for it, or, +/// for a value from another module, the one of [`value_error`]. +pub(crate) fn evaluate_styles<'a>( + expr: &'a Expression<'a>, + consts: &StringConsts<'a>, +) -> Result, String> { + const SUBJECT: &str = "@Component.styles"; + match Evaluator::new(consts).evaluate(expr) { + Value::String(style) => Ok(vec![style]), + Value::Array(entries) => entries + .into_iter() + .enumerate() + .map(|(position, entry)| match entry { + Value::String(style) => Ok(style), + entry => Err(value_error( + SUBJECT, + || format!("Failed to resolve styles at position {position} to a string"), + &entry, + )), + }) + .collect(), + value => Err(value_error( + SUBJECT, + || format!("Failed to resolve {SUBJECT} to a string or an array of strings"), + &value, + )), + } +} + /// The name upstream sees for a `@angular/core` decorator (`dec.import?.name /// ?? dec.name`): the imported name for an aliased import (`@Inj(...)` for /// `import { Inject as Inj }` is `Inject`), the member name for a namespace diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 761cae123..8c704144f 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -36,7 +36,7 @@ pub use decorator::{ find_directive_decorator_span, param_decorator_errors, }; pub(crate) use decorator::{ - angular_decorator_config, extract_string_value, resolve_template_literal, + angular_decorator_config, evaluate_styles, extract_string_value, resolve_template_literal, }; pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; diff --git a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs new file mode 100644 index 000000000..8485b3be5 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs @@ -0,0 +1,252 @@ +//! `styles` in `@Component` metadata is evaluated statically, as ngtsc's +//! `parseDirectiveStyles` does: a same-file constant, a concatenation, a spread of an +//! array or an array constant gives the same styles as the strings written out. A value +//! that cannot be used is an error on the `styles` expression, never a silent drop. +//! +//! Every expectation is what `@angular/compiler-cli` 22.2.1 produces for the same source +//! (with the chained line of its diagnostics joined by a space, as elsewhere). + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; +use oxc_diagnostics::OxcDiagnostic; + +/// Declared at the top of every source, as in the report. +const PREAMBLE: &str = "import { Component } from '@angular/core'; +import { OTHER } from './other'; +const S = '.a { color: red }'; +const LIST = ['.l { color: red }']; +"; + +fn source(preamble: &str, metadata: &str) -> String { + format!( + "{PREAMBLE}{preamble}\n@Component({{ selector: 'x', template: '', {metadata} }})\n\ + export class X {{}}\n" + ) +} + +fn compile(source: &str) -> (String, Vec) { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "/x/a.ts", + source, + Some(&TransformOptions::default()), + None, + ); + (result.code, result.diagnostics) +} + +/// The whitespace-free `styles` array of the compiled definition, or `None` when the +/// definition has none. +fn compiled_styles(preamble: &str, metadata: &str) -> Option { + let (code, diagnostics) = compile(&source(preamble, metadata)); + assert!(diagnostics.is_empty(), "unexpected diagnostics for `{metadata}`: {diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let definition = &compact[compact.find("i0.ɵɵdefineComponent(").expect("definition")..]; + let definition = &definition[..definition.find("(()=>{").unwrap_or(definition.len())]; + let start = definition.find("styles:[")?; + let end = definition[start..].find("\"]").map(|i| start + i + 2)?; + Some(definition[start..end].to_string()) +} + +/// `metadata` compiles to the styles that `literal` compiles to. +#[track_caller] +fn assert_same_as_literal(preamble: &str, metadata: &str, literal: &str) { + let expected = compiled_styles("", literal); + assert!(expected.is_some(), "`{literal}` should have styles"); + assert_eq!( + compiled_styles(preamble, metadata), + expected, + "`{metadata}` should compile like `{literal}`" + ); +} + +/// Compiling `styles: {value}` reports exactly `message`, on the value. +#[track_caller] +fn assert_error(preamble: &str, value: &str, message: &str) { + let source = source(preamble, &format!("styles: {value}")); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `styles: {value}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, message, "for `styles: {value}`"); + let labels: Vec<&str> = diagnostic + .labels + .iter() + .map(|l| &source[l.offset() as usize..(l.offset() + l.len()) as usize]) + .collect(); + assert_eq!(labels, [value], "label for `styles: {value}`"); +} + +const NOT_STRINGS: &str = "Failed to resolve @Component.styles to a string or an array of strings"; + +fn at(position: usize) -> String { + format!("Failed to resolve styles at position {position} to a string") +} + +// The rows of the report. + +#[test] +fn same_file_constants_already_supported() { + assert_same_as_literal("", "styles: [S]", "styles: ['.a { color: red }']"); + assert_same_as_literal("", "styles: S", "styles: ['.a { color: red }']"); +} + +#[test] +fn concatenation_of_strings() { + assert_same_as_literal("", "styles: ['.b{}' + '.c{}']", "styles: ['.b{}.c{}']"); + assert_same_as_literal( + "const A = '.n'; const B = A + '{}';", + "styles: [B, S + '.k{}']", + "styles: ['.n{}', '.a { color: red }.k{}']", + ); +} + +#[test] +fn spread_of_a_same_file_array() { + assert_same_as_literal("", "styles: [...LIST]", "styles: ['.l { color: red }']"); + assert_same_as_literal( + "", + "styles: ['.first{}', ...LIST, S]", + "styles: ['.first{}', '.l { color: red }', '.a { color: red }']", + ); +} + +#[test] +fn same_file_array_constant() { + assert_same_as_literal("", "styles: LIST", "styles: ['.l { color: red }']"); + assert_same_as_literal("const LISTC = ['.c{}'] as const;", "styles: LISTC", "styles: ['.c{}']"); + assert_same_as_literal( + "const INNER = ['.i{}']; const OUTER = [...INNER, S];", + "styles: OUTER", + "styles: ['.i{}', '.a { color: red }']", + ); +} + +// More that ngtsc evaluates. + +#[test] +fn constants_that_name_other_constants() { + assert_same_as_literal("const A = '.n{}'; const B = A;", "styles: [B]", "styles: ['.n{}']"); +} + +#[test] +fn template_literals() { + assert_same_as_literal("", "styles: [`.t { color: red }`]", "styles: ['.t { color: red }']"); + assert_same_as_literal( + "const C = 'red';", + "styles: [`.t { color: ${C} }`]", + "styles: ['.t { color: red }']", + ); + assert_same_as_literal( + "const C = 'red';", + "styles: `.t { color: ${C} }`", + "styles: ['.t { color: red }']", + ); +} + +/// ngtsc evaluates these too; none of them is an error. +#[test] +fn other_statically_resolvable_values() { + for (preamble, metadata, literal) in [ + ("let L = '.let{}';", "styles: [L]", "styles: ['.let{}']"), + ("function make() { return '.m{}'; }", "styles: [make()]", "styles: ['.m{}']"), + ("const DEV = true;", "styles: [DEV ? '.d{}' : '.p{}']", "styles: ['.d{}']"), + ( + "const C = { s: '.s{}', l: ['.l2{}'] };", + "styles: [C.s, ...C.l]", + "styles: ['.s{}', '.l2{}']", + ), + ] { + assert_same_as_literal(preamble, metadata, literal); + } +} + +#[test] +fn empty_and_blank_styles() { + assert_eq!(compiled_styles("", "styles: []"), None); + assert_eq!(compiled_styles("", "styles: ''"), None); + assert_eq!(compiled_styles("const NONE: string[] = [];", "styles: NONE"), None); + assert_same_as_literal("", "styles: ['', ' ', S]", "styles: ['.a { color: red }']"); +} + +/// A plain literal compiles exactly as it did before styles were evaluated. +#[test] +fn literal_styles_are_unchanged() { + assert_eq!( + compiled_styles("", "styles: ['.x { color: blue }', '.y{}']").as_deref(), + Some(r#"styles:[".x[_ngcontent-%COMP%]{color:blue}",".y[_ngcontent-%COMP%]{}"]"#) + ); + assert_eq!( + compiled_styles("", "styles: '.x { color: blue }'").as_deref(), + Some(r#"styles:[".x[_ngcontent-%COMP%]{color:blue}"]"#) + ); +} + +// Errors: what ngtsc reports, on the `styles` expression. + +#[test] +fn styles_that_are_not_a_string_or_an_array() { + for (value, suffix) in [ + ("1", "Value is of type 'number'."), + ("{}", "Value is of type '{}'."), + ("undefined", "Value is of type 'undefined'."), + ] { + assert_error("", value, &format!("{NOT_STRINGS} {suffix}")); + } +} + +#[test] +fn entry_that_is_not_a_string() { + for (preamble, value, position, suffix) in [ + ("", "[1]", 0, "Value is of type 'number'."), + ("", "[null]", 0, "Value is of type 'null'."), + ("", "[LIST, ['.z{}']]", 0, "Value is of type '[string]'."), + ("declare const X: string;", "[X]", 0, "Value is a reference to 'X'."), + ( + "declare function make(): string;", + "[make()]", + 0, + "Value could not be determined statically.", + ), + ("", "[NOPE]", 0, "Value could not be determined statically."), + ("", "[...S]", 0, "Value could not be determined statically."), + ("enum E { A = '.e{}' }", "[E.A]", 0, "Value is of type 'E'."), + // The first entry that is not a string is the one reported. + ("", "[S, 1, true]", 1, "Value is of type 'number'."), + ] { + assert_error(preamble, value, &format!("{} {suffix}", at(position))); + } +} + +/// ngtsc reads another file to evaluate an import. One file at a time, that is an error +/// that says so, wherever the import appears. +#[test] +fn styles_that_depend_on_an_import() { + let message = "@Component.styles depends on 'OTHER', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files."; + for value in ["[OTHER]", "OTHER", "[S, OTHER]", "[OTHER + '.k{}']", "[...OTHER]"] { + let source = source("", &format!("styles: {value}")); + let (_, diagnostics) = compile(&source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic for `styles: {value}`, got {diagnostics:?}"); + }; + assert_eq!(diagnostic.message, message, "for `styles: {value}`"); + } +} + +// `styleUrl` and `styleUrls` alongside `styles`. + +#[test] +fn style_urls_alongside_evaluated_styles() { + let source = source("", "styles: [...LIST, S + '.k{}'], styleUrls: ['./a.css']"); + let (code, diagnostics) = compile(&source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains( + r#"styles:[".l[_ngcontent-%COMP%]{color:red}",".a[_ngcontent-%COMP%]{color:red}.k[_ngcontent-%COMP%]{}"]"# + ), + "{compact}" + ); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts new file mode 100644 index 000000000..36cc8e269 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/styles/styles-static-evaluation.fixture.ts @@ -0,0 +1,55 @@ +/** + * `styles` values that are not string literals: ngtsc evaluates them statically. + */ +import type { Fixture } from '../types.js' + +const fixture = (name: string, description: string, declarations: string, styles: string) => ({ + name: `styles-evaluated-${name}`, + category: 'styles', + description, + className: 'StylesComponent', + type: 'full-transform' as const, + sourceCode: ` +import { Component } from '@angular/core'; + +const S = '.a { color: red }'; +const LIST = ['.l { color: red }']; +${declarations} + +@Component({ + selector: 'app-styles', + standalone: true, + template: '

x

', + styles: ${styles}, +}) +export class StylesComponent {} +`, + expectedFeatures: ['ɵɵdefineComponent'], +}) + +export const fixtures: Fixture[] = [ + fixture('constant', 'A string constant in the array', '', '[S]'), + fixture('constant-alone', 'A string constant as the styles', '', 'S'), + fixture('concatenation', 'A concatenation of strings', '', `['.b{}' + '.c{}', S + '.k{}']`), + fixture('spread', 'A spread of an array constant', '', `['.first{}', ...LIST, S]`), + fixture('array-constant', 'An array constant as the styles', '', 'LIST'), + fixture( + 'nested-constants', + 'Constants that are built from other constants', + `const INNER = ['.i{}']; const OUTER = [...INNER, S]; const A = '.n'; const B = A + '{}';`, + '[...OUTER, B]', + ), + fixture( + 'template-literal', + 'A template literal with a constant substitution', + `const C = 'red';`, + '[`.t { color: ${C} }`]', + ), + fixture( + 'call-and-member', + 'A same-file function call, a ternary and members of an object constant', + `function make() { return '.m{}'; } const DEV = true; const C = { s: '.s{}', l: ['.l2{}'] };`, + `[make(), DEV ? '.d{}' : '.p{}', C.s, ...C.l]`, + ), + fixture('blank-entries', 'Empty and blank entries are left out', '', `['', ' ', S]`), +] diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 4226accc7..d02d013cc 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { extractComponentMetadataSync } from '../index.js' +import { extractComponentMetadataSync, transformAngularFileSync } from '../index.js' // The queries reported must be the ones the component is compiled with. describe('extractComponentMetadataSync queries', () => { @@ -169,3 +169,48 @@ export class Widget {} expect(components.map((c) => c.className)).toEqual(['A', 'B']) }) }) + +// The styles reported must be the ones the component is compiled with. +describe('extractComponentMetadataSync styles', () => { + const source = (styles: string) => ` +import { Component } from '@angular/core'; +import { OTHER } from './other'; + +const S = '.a{}'; +const LIST = ['.l{}']; + +@Component({ selector: 'app-x', template: '', styles: ${styles} }) +export class X {} +` + + it.each([ + ['[S]', ['.a{}']], + ['S', ['.a{}']], + ["['.b{}' + '.c{}']", ['.b{}.c{}']], + ['[...LIST]', ['.l{}']], + ['LIST', ['.l{}']], + ['[]', []], + ])('evaluates `styles: %s` as the transform does', (styles, expected) => { + const [component] = extractComponentMetadataSync(source(styles), 'x.component.ts') + expect(component.styles).toEqual(expected) + + const result = transformAngularFileSync(source(styles), 'x.component.ts') + expect(result.errors).toEqual([]) + for (const style of expected) { + expect(result.code).toContain(style.replaceAll('{}', '[_ngcontent-%COMP%]{}')) + } + }) + + it.each(['[OTHER]', '[S, OTHER]', '[S, 1]'])( + 'reports `styles: %s` instead of dropping an entry', + (styles) => { + const code = source(styles) + const [error] = transformAngularFileSync(code, 'x.component.ts').errors + expect(error.message).toMatch( + /^(@Component\.styles depends on 'OTHER'|Failed to resolve styles at position 1 to a string)/, + ) + expect(error.labels.map((label) => code.slice(label.start, label.end))).toEqual([styles]) + expect(extractComponentMetadataSync(code, 'x.component.ts')[0].styles).toEqual([]) + }, + ) +}) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 058c8472e..0a0725a35 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -2949,16 +2949,13 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) - it('serves `styles: []` for a class whose `styles` names an array constant', async () => { + it('serves the styles of a class whose `styles` names an array constant', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) const constPath = join(appDir, 'ps457-const.component.ts') - // `collect_string_consts` folds STRING-valued consts only, never an array - // one, so `styles: PS457_STYLES` resolves to nothing. Measured against the - // real compile: this class's `\u0275cmp` carries no `styles` key at all and - // never held `.PS457_CONST_MARKER`. So `styles: []` is the exact answer — - // it clears nothing, because the running component never had these styles. + // `styles` is evaluated statically, so `styles: PS457_STYLES` compiles with + // the constant's styles, and the update carries the same ones. const source = ` import { Component } from '@angular/core'; const PS457_STYLES = ['.PS457_CONST_MARKER { color: red; }']; @@ -2984,10 +2981,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_CONST_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_CONST_MARKER[_ngcontent-%COMP%]') }) - it('serves `styles: []` for an array-constant `styles` beside an empty sibling stylesheet', async () => { + it('serves an array-constant `styles` beside an empty sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2998,10 +2994,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { writeFileSync(fbEmptyCssPath, '') // Two classes. `FbEmptyOwnerComponent` declares `styles: PS457_FB_STYLES`, - // an array constant the compiler does not fold, so it compiles with no - // styles (measured) and `styles: []` is exact. What this pins is that the - // SIBLING has no say: its stylesheet is neither consulted for this class's - // answer nor able to turn it into something else. + // an array constant, and compiles with its styles. What this pins is that + // the SIBLING has no say: its stylesheet is neither consulted for this + // class's answer nor able to turn it into something else. const source = ` import { Component } from '@angular/core'; const PS457_FB_STYLES = ['.PS457_FB_MARKER { color: red; }']; @@ -3033,11 +3028,11 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_FB_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_FB_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) - it('serves `styles: []` for an array-constant `styles` beside a whitespace sibling stylesheet', async () => { + it('serves an array-constant `styles` beside a whitespace sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3078,8 +3073,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('PS457_FB_WS_MARKER') - expect(body).toContain('styles: []') + expect(body).toContain('.PS457_FB_WS_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) // A read that FAILED on one stylesheet but succeeded on another is not the @@ -3445,14 +3440,14 @@ describe('@ng/component endpoint resolves the styles per class', () => { // upstream `load` / `transform: { order: 'pre' }` rewrote the module, or // this plugin's own `fileReplacements` pointed `actualId` at another file. // - // For every form the extractor cannot fold, the disk parse yields NO + // For every form the extractor cannot evaluate, the disk parse yields NO // styles — which, taken as definitive, emits `styles: []` and wipes CSS // the component really does have. So an empty answer may only clear when // the stripped disk source matches the stripped source the component // compiled from; otherwise it is unknown and `styles` is omitted. // ---------------------------------------------------------------- - it('keeps the styles of a class whose disk `styles` names an array constant the transform never saw', async () => { + it('serves the styles of a class whose disk `styles` names an array constant the transform never saw', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3460,8 +3455,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { const arrPath = join(appDir, 'ps458-arr.component.ts') writeFileSync(arrHtmlPath, '

one

') - // What is ON DISK: `collect_string_consts` folds string consts only, so - // re-parsing this shape resolves no styles at all. + // What is ON DISK: an array constant, which evaluates to the same styles + // the rewritten code compiled with. const diskSource = ` import { Component } from '@angular/core'; const PS458_ARR = ['.PS458_ARR_MARKER { color: red; }']; @@ -3502,9 +3497,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body, 'an unreadable `styles` must stay unknown, not clear the live CSS').not.toContain( - 'styles:', - ) + expect(body).toContain('.PS458_ARR_MARKER[_ngcontent-%COMP%]') + expect(body).not.toContain('styles: []') }) it('keeps the styles of a class whose disk `styles` names an imported constant', async () => { @@ -3519,8 +3513,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { `export const PS458_IMP = ['.PS458_IMP_MARKER { color: red; }'];\n`, ) - // A cross-file reference is unreadable to the extractor for the same - // reason a same-file array constant is. + // A cross-file reference is unreadable to the extractor, which sees one + // file: the answer is unknown and must not clear the live CSS. const diskSource = ` import { Component } from '@angular/core'; import { PS458_IMP } from './ps458-imp-styles'; @@ -3559,7 +3553,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) - it('keeps the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { + it('serves the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -3567,9 +3561,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { const catPath = join(appDir, 'ps458-cat.component.ts') writeFileSync(catHtmlPath, '

one

') - // A call expression is unreadable to the extractor AND unreadable to the - // strip (its value has no `[` / quote opener), so the stripped disk form - // keeps the call verbatim while the stripped transform form is `styles: []`. + // A call the strip cannot read (its value has no `[` / quote opener), but + // one that evaluates statically, to the styles the rewritten code has. const diskSource = ` import { Component } from '@angular/core'; const PS458_CAT_BASE = ['.PS458_CAT_MARKER { color: red; }']; @@ -3605,7 +3598,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('styles:') + expect(body).toContain('.PS458_CAT_MARKER[_ngcontent-%COMP%]') + expect(body).toContain('.PS458_CAT_EXTRA[_ngcontent-%COMP%]') }) // The counterweight: when the endpoint IS looking at the source the diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 10972e2b4..955d31505 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -792,8 +792,8 @@ export function angular(options: PluginOptions = {}): Plugin[] { // `fileReplacements` pointed `actualId` at a different file. // // On a disk source the compiler never saw, every `styles` - // shape the extractor cannot fold — an array constant, an - // imported one, a `.concat(...)` — resolves to nothing. Read + // shape the extractor cannot evaluate (an imported constant, + // a call to an imported function) resolves to nothing. Read // as definitive that emits `styles: []` and wipes CSS the // running component genuinely has, from a template edit that // never touched the styles at all. From afd133170c80d3c259452fdd52867c134b3c54d1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 14:41:23 +0800 Subject: [PATCH 2/4] fix(compiler): report styles errors after the constructor checks, keep enum identity across files, and flag unresolved styles for HMR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review fixes on top of the styles static-evaluation port: - Ordering: ngtsc's getConstructorDependencies throws inside extractDirectiveMetadata, before the component handler reads `styles` (parseDirectiveStyles). Emitting the styles error from decorator_io_errors suppressed the parameter errors; it now runs only when the io and constructor checks pass. - Enums: ngtsc keeps EnumValue identity across module resolution, so `styles: [E.A]` from an import is the same `Value is of type 'E'` error as a same-file one. StaticValue::to_static unwrapped the enum to its value and the import silently compiled as a string; it now round-trips through StaticValue::Enum. - HMR: extractComponentMetadataSync evaluates `styles` without resolveImportedValues, so imported styles extract as an empty array — an unknown answer the vite plugin served as "clear the styles", wiping live CSS. ComponentMetadata and ExtractedComponentMetadata now carry `styles_resolved`, and the plugin only clears when it is true. Co-Authored-By: Grok --- .../src/component/decorator.rs | 20 ++++-- .../src/component/metadata.rs | 7 ++ .../src/component/transform.rs | 11 ++- .../src/directive/decorator.rs | 26 ++++--- .../src/directive/evaluator.rs | 17 ++++- .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../tests/cross_file_values_test.rs | 71 +++++++++++++++++++ .../tests/styles_evaluation_test.rs | 42 +++++++++++ napi/angular-compiler/index.d.ts | 6 ++ napi/angular-compiler/src/lib.rs | 5 ++ napi/angular-compiler/vite-plugin/index.ts | 10 ++- 11 files changed, 196 insertions(+), 20 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 2411b295f..b9835c105 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -106,12 +106,20 @@ pub fn extract_component_metadata<'a>( crate::directive::extract_string_value(allocator, &prop.value, consts); } "styles" => { - // Anything that isn't styles is an error `decorator_io_errors` reports. - if let Ok(styles) = crate::directive::evaluate_styles(&prop.value, consts) { - metadata.styles = Vec::from_iter_in( - styles.iter().map(|style| Ident::from(allocator.alloc_str(style))), - &allocator, - ); + // Anything that isn't styles is an error `styles_error` reports. + match crate::directive::evaluate_styles(&prop.value, consts) { + Ok(styles) => { + metadata.styles = Vec::from_iter_in( + styles.iter().map(|style| Ident::from(allocator.alloc_str(style))), + &allocator, + ); + } + Err(_) => { + // Failed evaluation is reported as a diagnostic; + // here it marks the value unknown so readers do + // not mistake the empty array for "no styles". + metadata.styles_resolved = false; + } } } "styleUrls" | "styleUrl" => { diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index deed550c0..e1c412075 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -241,6 +241,12 @@ pub struct ComponentMetadata<'a> { /// Inline styles array. pub styles: Vec<'a, Ident<'a>>, + /// `false` when the decorator had a `styles` field the static evaluator + /// could not resolve (e.g. an import `resolveImportedValues` does not + /// cover): `styles` is then empty and must not be read as "no styles". + /// `true` when `styles` was absent or fully evaluated. + pub styles_resolved: bool, + /// URLs to external stylesheet files. pub style_urls: Vec<'a, Ident<'a>>, @@ -659,6 +665,7 @@ impl<'a> ComponentMetadata<'a> { template: None, template_url: None, styles: Vec::new_in(&allocator), + styles_resolved: true, style_urls: Vec::new_in(&allocator), standalone: implicit_standalone, encapsulation: ViewEncapsulation::default(), diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index daa9e0f34..79d14d65e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -44,7 +44,7 @@ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, decorator_io_errors, extract_class_queries, extract_directive_metadata, - find_directive_decorator, generate_directive_definitions, param_decorator_errors, + find_directive_decorator, generate_directive_definitions, param_decorator_errors, styles_error, }; use crate::dts; use crate::injectable::{ @@ -3382,7 +3382,14 @@ pub fn transform_angular_file( // throws earlier — malformed decorator args, a missing // selector — for shapes oxc doesn't diagnose yet, so this // can be the first error where ngtsc would report another. - result.diagnostics.extend(param_decorator_errors(class, &string_consts)); + let param_errors = param_decorator_errors(class, &string_consts); + result.diagnostics.extend(param_errors.iter().cloned()); + if param_errors.is_empty() { + // `styles` resolves in the component handler, after + // `extractDirectiveMetadata` — including the constructor + // checks above — has passed. + result.diagnostics.extend(styles_error(class, &string_consts)); + } } if let Some(mut metadata) = extract_component_metadata( diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index f7997dab2..e923a5b7c 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -1436,14 +1436,6 @@ pub fn decorator_io_errors<'a>( let selector = || directive_selector_error(config, decorator_name, &class_name, &evaluator, consts); - // The component handler reads `styles` once the directive metadata is extracted. - let styles = || { - let expr = config - .filter(|_| decorator_name == "Component") - .and_then(|config| config_property(config, "styles", consts))?; - evaluate_styles(expr, consts).err().map(|message| (message, expr.span(), None)) - }; - io.as_ref() .and_then(|io| io.input_error.clone()) .map(at) @@ -1453,7 +1445,6 @@ pub fn decorator_io_errors<'a>( .or_else(member_queries) .or_else(queries) .or_else(selector) - .or_else(styles) .map(|(message, span, related)| { let diagnostic = OxcDiagnostic::error(message).with_label(span); match related { @@ -1499,6 +1490,23 @@ fn directive_selector_error<'a>( None } +/// The `styles` error a `@Component` reports, evaluated lazily like the other +/// decorator checks. +/// +/// Kept out of [`decorator_io_errors`] on purpose: ngtsc reads `styles` in the +/// component handler, after `extractDirectiveMetadata` has already thrown for +/// the io, query, selector and constructor-parameter checks, so this error +/// must only surface when those pass. +pub fn styles_error<'a>(class: &'a Class<'a>, consts: &StringConsts<'a>) -> Option { + let (config, decorator_name) = angular_decorator_config(class, consts)?; + let expr = config + .filter(|_| decorator_name == "Component") + .and_then(|config| config_property(config, "styles", consts))?; + evaluate_styles(expr, consts) + .err() + .map(|message| OxcDiagnostic::error(message).with_label(expr.span())) +} + /// ngtsc's `parseDirectiveStyles`: the `styles` of a `@Component`, evaluated /// statically. A string is one style, and every entry of an array must be a /// string (`isStringArrayOrDie`). Anything else is ngtsc's error for it, or, diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index b4e27834a..64af4bbaa 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -1221,6 +1221,14 @@ pub(crate) enum StaticValue { Array(Vec), /// Object literal properties. Later duplicates win, like [`Value::prop`]. Object(Vec<(String, StaticValue)>), + /// A member of an enum in the resolved file. Identity is kept (ngtsc + /// preserves `EnumValue` across module resolution) so a reader that + /// rejects enums — `styles: [Enum.A]` is a `Value is of type 'Enum'` + /// error, not a string — rejects them from an import too. + Enum { + name: String, + value: Box, + }, } impl StaticValue { @@ -1242,8 +1250,10 @@ impl StaticValue { .map(|p| Self::to_static(&p.value).map(|v| (p.key.clone(), v))) .collect::>() .map(Self::Object), - // An enum member carries its resolved value. - Value::Enum { value, .. } => Self::to_static(value), + // An enum member keeps its enum identity, like ngtsc's EnumValue + // travelling across module resolution. + Value::Enum { name, value } => Self::to_static(value) + .map(|value| Self::Enum { name: name.clone(), value: Box::new(value) }), _ => None, } } @@ -1270,6 +1280,9 @@ impl StaticValue { }) .collect(), ), + Self::Enum { name, value } => { + Value::Enum { name: name.clone(), value: Box::new(value.to_value()) } + } } } } diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 8c704144f..105bede4f 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -37,6 +37,7 @@ pub use decorator::{ }; pub(crate) use decorator::{ angular_decorator_config, evaluate_styles, extract_string_value, resolve_template_literal, + styles_error, }; pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; diff --git a/crates/oxc_angular_compiler/tests/cross_file_values_test.rs b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs index 8a950159a..fe1083248 100644 --- a/crates/oxc_angular_compiler/tests/cross_file_values_test.rs +++ b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs @@ -237,3 +237,74 @@ export class A {} error_messages(&result) ); } + +// `styles` in `@Component` resolves through the same imported bindings. + +#[test] +fn imported_styles_array_evaluates() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const STYLES = ['.a { color: red }'];"); + let result = transform( + &dir, + r#"import { Component } from '@angular/core'; +import { STYLES } from './meta'; +@Component({ selector: 'x', template: '', styles: STYLES }) +export class X {} +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "X").contains(r#"styles:[".a[_ngcontent-%COMP%]{color:red}"]"#), + "{}", + define_call(&result.code, "X") + ); +} + +/// ngtsc keeps `EnumValue` identity across module resolution: an imported +/// enum member in `styles` is the same `Value is of type 'E'` error a +/// same-file one reports, not a string silently unwrapped to its value. +#[test] +fn imported_enum_member_is_not_a_string() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export enum E { A = '.a { color: red }' }"); + let result = transform( + &dir, + r#"import { Component } from '@angular/core'; +import { E } from './meta'; +@Component({ selector: 'x', template: '', styles: [E.A] }) +export class X {} +"#, + &resolve_options(), + ); + assert!(result.has_errors()); + assert!( + error_messages(&result) + .contains("Failed to resolve styles at position 0 to a string Value is of type 'E'."), + "{}", + error_messages(&result) + ); +} + +/// Where a same-file enum member acts as its value (a string operand of +/// `+`), an imported one does too. +#[test] +fn imported_enum_member_in_concatenation() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export enum E { A = '.a' }"); + let result = transform( + &dir, + r#"import { Component } from '@angular/core'; +import { E } from './meta'; +@Component({ selector: 'x', template: '', styles: [E.A + ' { color: red }'] }) +export class X {} +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "X").contains(r#"styles:[".a[_ngcontent-%COMP%]{color:red}"]"#), + "{}", + define_call(&result.code, "X") + ); +} diff --git a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs index 8485b3be5..0ee9df69e 100644 --- a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs +++ b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs @@ -250,3 +250,45 @@ fn style_urls_alongside_evaluated_styles() { "{compact}" ); } + +/// ngtsc reads `styles` in the component handler, after +/// `extractDirectiveMetadata` has already thrown for the constructor checks. +/// A component with an invalid constructor parameter AND invalid `styles` +/// reports the parameter error, not the styles one. +#[test] +fn constructor_errors_come_before_styles_errors() { + let source = "import { Component, Inject } from '@angular/core'; +import { OTHER } from './other'; +@Component({ selector: 'x', template: '', styles: [OTHER] }) +export class X { + constructor(@Inject() t: unknown) {} +} +"; + let (_, diagnostics) = compile(source); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected exactly one diagnostic, got {diagnostics:?}"); + }; + assert!( + diagnostic.message.contains("Unexpected number of arguments to @Inject()"), + "expected the constructor error first, got: {diagnostic:?}" + ); +} + +/// ngtsc keeps `EnumValue` identity across module resolution, so an imported +/// enum member is a wrong-type error, same as a same-file one. The evaluator +/// must not unwrap the enum to its value when a binding crosses files +/// (checked same-file here; the cross-file path shares `Value::Enum`). +#[test] +fn enum_members_are_not_strings() { + let source = "enum E { A = '.a{}' } +import { Component } from '@angular/core'; +const _ = E.A; +@Component({ selector: 'x', template: '', styles: [E.A] }) +export class X {} +"; + let (_, diagnostics) = compile(source); + assert!( + diagnostics.iter().any(|d| d.message.contains("Failed to resolve styles at position 0")), + "expected the position-0 styles error, got {diagnostics:?}" + ); +} diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 7a37a173a..c64d16050 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -349,6 +349,12 @@ export interface ExtractedComponentMetadata { templateUrl?: string /** Inline styles array. */ styles: Array + /** + * `false` when `styles` could not be statically evaluated (e.g. it + * references an imported value): `styles` is then empty and must be + * treated as "unknown", not as "the component has no styles". + */ + stylesResolved: boolean /** URLs to external stylesheet files. */ styleUrls: Array /** Whether this is a standalone component. */ diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index a7779bfa6..69fcec1b9 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1608,6 +1608,10 @@ pub struct ExtractedComponentMetadata { pub template_url: Option, /// Inline styles array. pub styles: Vec, + /// `false` when `styles` could not be statically evaluated (e.g. it + /// references an imported value): `styles` is then empty and must be + /// treated as "unknown", not as "the component has no styles". + pub styles_resolved: bool, /// URLs to external stylesheet files. pub style_urls: Vec, /// Whether this is a standalone component. @@ -1893,6 +1897,7 @@ pub fn extract_component_metadata_sync( .as_ref() .map(std::string::ToString::to_string), styles: metadata.styles.iter().map(std::string::ToString::to_string).collect(), + styles_resolved: metadata.styles_resolved, style_urls: metadata .style_urls .iter() diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 8ad1682cd..794e0dfc4 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -821,8 +821,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { stripComponentMetadata(source, componentDecoratorsByFile.get(fileId)) ) } + // `stylesResolved` is the third leg of "may we clear": the + // extractor here evaluates `styles` without the + // `resolveImportedValues` the transform runs with, so a + // component whose styles live in another file extracts + // `styles: []` — an UNKNOWN answer, not an empty one. Serving + // it as definitive would wipe live CSS that the transform + // compiled from the import. const styles: string[] | null = - merged.length > 0 || (external.complete && compiledFromThisSource()) + merged.length > 0 || + (external.complete && compiledFromThisSource() && classMetadata.stylesResolved) ? merged : null From 23516a8c18761792996ffa52cf6a334490178e26 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 14:50:23 +0800 Subject: [PATCH 3/4] fix(e2e): set stylesResolved on TypeScript-extracted metadata ExtractedComponentMetadata gained stylesResolved; the finder builds the type from the TypeScript extractor's output, which always resolves styles itself. --- napi/angular-compiler/e2e/compare/src/discovery/finder.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/napi/angular-compiler/e2e/compare/src/discovery/finder.ts b/napi/angular-compiler/e2e/compare/src/discovery/finder.ts index bcf8c2fa7..4e80f174b 100644 --- a/napi/angular-compiler/e2e/compare/src/discovery/finder.ts +++ b/napi/angular-compiler/e2e/compare/src/discovery/finder.ts @@ -105,6 +105,9 @@ async function extractComponentsFromFile( template: ts.template, templateUrl: ts.templateUrl, styles: ts.styles || [], + // The TypeScript extractor reads `styles` itself, so they are always + // resolved here. + stylesResolved: true, styleUrls: ts.styleUrls || [], standalone: ts.standalone ?? true, encapsulation: ts.encapsulation || 'Emulated', From 5d250356d36bdd30734b2d303009159783cdb87f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 15:27:46 +0800 Subject: [PATCH 4/4] fix(compiler): fold duplicate styles keys last-wins, and serve no HMR styles when the extractor cannot evaluate them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of deep-review fixes: - Duplicate `styles:` properties: ngtsc's reflectObjectLiteral folds them with Map.set, so the last key is the only one parseDirectiveStyles sees. extract_component_metadata kept the last SUCCESSFUL entry — `{styles:['a'], styles:1}` emitted 'a' alongside the error for `1` — and computed `[K]: styles` entries leaked into emit that upstream drops. Each `styles` prop now fully overwrites (computed keys skipped), matching the last-wins semantics `config_property` already gives the diagnostic path. - HMR: `stylesResolved` only gated the empty-answer case. A component with `styles: [IMPORTED]` + `styleUrls: ['./a.css']` merged external CSS alone and served it as definitive, dropping the imported inline styles the transform compiled. An unresolvable `styles` field now makes the whole answer `null` regardless of what external contents were read. - Tests: the imported-constant HMR test now transforms the disk source verbatim (as Vite does), so stylesResolved — not the strip-match fallback — is the leg being exercised; a new test pins the imported-styles + styleUrls mix; a Rust test pins last-wins. Co-Authored-By: Grok --- .../src/component/decorator.rs | 11 +++ .../tests/styles_evaluation_test.rs | 27 ++++++++ .../test/hmr-hot-update.test.ts | 67 +++++++++++++++++-- napi/angular-compiler/vite-plugin/index.ts | 14 ++-- 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index b9835c105..389baca01 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -106,6 +106,12 @@ pub fn extract_component_metadata<'a>( crate::directive::extract_string_value(allocator, &prop.value, consts); } "styles" => { + // ngtsc's `reflectObjectLiteral` drops computed keys and + // folds duplicate `styles` entries with `Map.set` — the + // last one is the only one `parseDirectiveStyles` sees. + if prop.computed { + continue; + } // Anything that isn't styles is an error `styles_error` reports. match crate::directive::evaluate_styles(&prop.value, consts) { Ok(styles) => { @@ -113,11 +119,16 @@ pub fn extract_component_metadata<'a>( styles.iter().map(|style| Ident::from(allocator.alloc_str(style))), &allocator, ); + // A later `styles:` overwrites an earlier failed + // one: `Map.set` makes the last entry the only + // one upstream reads. + metadata.styles_resolved = true; } Err(_) => { // Failed evaluation is reported as a diagnostic; // here it marks the value unknown so readers do // not mistake the empty array for "no styles". + metadata.styles.clear(); metadata.styles_resolved = false; } } diff --git a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs index 0ee9df69e..ea6a093e7 100644 --- a/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs +++ b/crates/oxc_angular_compiler/tests/styles_evaluation_test.rs @@ -292,3 +292,30 @@ export class X {} "expected the position-0 styles error, got {diagnostics:?}" ); } + +/// ngtsc folds duplicate `styles` properties into a Map — the last key is the +/// only one `parseDirectiveStyles` sees. +#[test] +fn the_last_styles_property_wins() { + // A bad `styles` a later good one shadows is never read upstream. + let (code, diagnostics) = compile(&source("", "styles: 1, styles: ['.a { color: red }']")); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert!(compiled_styles("", "styles: 1, styles: ['.a { color: red }']").is_some()); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(r#"styles:[".a[_ngcontent-%COMP%]{color:red}"]"#), "{compact}"); + + // A good `styles` a later bad one shadows is fully replaced, error and all. + let (code, diagnostics) = compile(&source("", "styles: ['.a { color: red }'], styles: 1")); + let [diagnostic] = diagnostics.as_slice() else { + panic!("expected one diagnostic, got {diagnostics:?}"); + }; + assert_eq!( + diagnostic.message, + "Failed to resolve @Component.styles to a string or an array of strings Value is of type \ + 'number'.", + ); + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let definition = &compact[compact.find("i0.ɵɵdefineComponent(").expect("definition")..]; + let definition = &definition[..definition.find("(()=>{").unwrap_or(definition.len())]; + assert!(!definition.contains("styles:"), "{compact}"); +} diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 0a0725a35..3df869bec 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -3514,7 +3514,11 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) // A cross-file reference is unreadable to the extractor, which sees one - // file: the answer is unknown and must not clear the live CSS. + // file: the answer is unknown and must not clear the live CSS. The + // transform runs on the source exactly as it sits on disk — Vite hands + // `transform` the file content and resolveImportedValues follows the + // import — so disk source and compiled source are byte-identical and the + // stylesResolved leg, not the strip match, is what keeps the styles. const diskSource = ` import { Component } from '@angular/core'; import { PS458_IMP } from './ps458-imp-styles'; @@ -3527,11 +3531,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { ` writeFileSync(impPath, diskSource) - const transformCode = diskSource.replace( - 'styles: PS458_IMP', - `styles: ['.PS458_IMP_MARKER { color: red; }']`, - ) - const transformed = await transformSource(plugin, transformCode, impPath) + const transformed = await transformSource(plugin, diskSource, impPath) expect(transformed?.code ?? '').toContain('PS458_IMP_MARKER') writeFileSync(impHtmlPath, '

two

') @@ -3553,6 +3553,61 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) + it('keeps the styles of a class mixing imported `styles` with `styleUrls`', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const mixHtmlPath = join(appDir, 'ps458-mix.component.html') + const mixPath = join(appDir, 'ps458-mix.component.ts') + writeFileSync(mixHtmlPath, '

one

') + writeFileSync( + join(appDir, 'ps458-mix-styles.ts'), + `export const PS458_MIX = ['.PS458_MIX_MARKER { color: red; }'];\n`, + ) + writeFileSync(join(appDir, 'ps458-mix.css'), '.PS458_MIX_EXT { color: blue; }\n') + + // The transform resolves the import and compiles BOTH style lists. The + // extractor resolves neither `PS458_MIX` nor the inline styles the import + // holds — merged would be only the external CSS, a partial answer that + // must not be served as the component's styles. + const diskSource = ` + import { Component } from '@angular/core'; + import { PS458_MIX } from './ps458-mix-styles'; + @Component({ + selector: 'app-ps458-mix', + templateUrl: './ps458-mix.component.html', + styles: PS458_MIX, + styleUrls: ['./ps458-mix.css'], + }) + export class MixedStylesComponent {} + ` + writeFileSync(mixPath, diskSource) + + const transformed = await transformSource(plugin, diskSource, mixPath) + expect(transformed?.code ?? '').toContain('PS458_MIX_MARKER') + expect(transformed?.code ?? '').toContain('PS458_MIX_EXT') + + writeFileSync(mixHtmlPath, '

two

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(mixHtmlPath), + [{ id: normalizePath(mixHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixPath}@MixedStylesComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + // Serving only the external CSS here would silently drop the imported + // inline styles the component was compiled with. + expect(body).not.toContain('styles:') + }) + it('serves the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 794e0dfc4..8f8ffe41f 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -821,16 +821,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { stripComponentMetadata(source, componentDecoratorsByFile.get(fileId)) ) } - // `stylesResolved` is the third leg of "may we clear": the - // extractor here evaluates `styles` without the + // `stylesResolved` comes first, ahead of "serve what was + // read": the extractor here evaluates `styles` without the // `resolveImportedValues` the transform runs with, so a // component whose styles live in another file extracts // `styles: []` — an UNKNOWN answer, not an empty one. Serving // it as definitive would wipe live CSS that the transform - // compiled from the import. + // compiled from the import, and serving `merged` when it is + // non-empty is no better: it holds only the styleUrls + // content, so the imported inline styles are dropped either + // way. An unknown `styles` field makes the merged array + // itself unrepresentable, so the answer is `null`. const styles: string[] | null = - merged.length > 0 || - (external.complete && compiledFromThisSource() && classMetadata.stylesResolved) + classMetadata.stylesResolved && + (merged.length > 0 || (external.complete && compiledFromThisSource())) ? merged : null