diff --git a/napi/angular-compiler/test/decorator-fields.test.ts b/napi/angular-compiler/test/decorator-fields.test.ts index efa0ba655..816d14c9a 100644 --- a/napi/angular-compiler/test/decorator-fields.test.ts +++ b/napi/angular-compiler/test/decorator-fields.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest' import { emptyDelimitedRange, locateComponentDecorators, + locateStyleFieldsFor, + locateStyleUrlFor, + locateStyleUrlsFor, locateStylesFieldFor, locateTemplateStringFor, locateTemplateUrlFor, + readStringLiterals, } from '../vite-plugin/utils/decorator-fields.js' describe('decorator-fields utils', () => { @@ -55,6 +59,79 @@ describe('decorator-fields utils', () => { expect(locateComponentDecorators(src)).toEqual([]) }) + it('ignores a commented-out decorator that follows the real one', () => { + // The phantom sits between the real decorator and the class. Pairing + // the class with it would read the stale metadata, not merely miss it. + const src = [ + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `// @Component({ styleUrl: './old.css' })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('FooComponent') + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a decorator inside a block comment that follows the real one', () => { + const src = [ + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `/* @Component({ styleUrl: './old.css' }) */`, + `export class FooComponent {}`, + ].join('\n') + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a commented-out decorator that precedes the real one', () => { + const src = [ + `// @Component({ styleUrl: './old.css' })`, + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('ignores a decorator written inside a string literal', () => { + const src = [ + `const doc = 'see @Component({ styleUrl: "./str.css" }) for details';`, + `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `export class FooComponent {}`, + ].join('\n') + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ + './real.css', + ]) + }) + + it('pairs each class with its own decorator when a phantom sits between them', () => { + const src = [ + `@Component({ selector: 'a', styleUrls: ['./a.css'] })`, + `// @Component({ styleUrl: './fake.css' })`, + `export class AComponent {}`, + `@Component({ selector: 'b', styleUrls: ['./b.css'] })`, + `export class BComponent {}`, + ].join('\n') + expect(locateComponentDecorators(src).map((d) => d.className)).toEqual([ + 'AComponent', + 'BComponent', + ]) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'AComponent')!).literals).toEqual([ + './a.css', + ]) + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'BComponent')!).literals).toEqual([ + './b.css', + ]) + }) + it('returns a single entry for a single-component file', () => { const src = `@Component({ selector: 'x' })\nexport class FooComponent {}` const out = locateComponentDecorators(src) @@ -304,13 +381,802 @@ describe('decorator-fields utils', () => { }) }) + describe('locateStyleUrlsFor / locateStyleUrlFor', () => { + const multi = ` + @Component({ selector: 'a', styleUrls: ['./first.css'] }) + export class FirstComponent {} + @Component({ selector: 'b', styleUrls: ['./second.css', './extra.css'] }) + export class SecondComponent {} + ` + + it('returns null when className matches no decorator', () => { + expect(locateStyleUrlsFor(multi, 'Nope')).toBeNull() + expect(locateStyleUrlFor(multi, 'Nope')).toBeNull() + }) + + it('returns each component its own styleUrls range in a multi-component file', () => { + const first = locateStyleUrlsFor(multi, 'FirstComponent')! + const second = locateStyleUrlsFor(multi, 'SecondComponent')! + expect(multi.slice(first[0], first[1] + 1)).toBe(`['./first.css']`) + expect(multi.slice(second[0], second[1] + 1)).toBe(`['./second.css', './extra.css']`) + }) + + it('locates the singular `styleUrl:` string form', () => { + const src = `@Component({ styleUrl: './solo.css' })\nexport class Foo {}` + const range = locateStyleUrlFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`'./solo.css'`) + }) + + // The four cross-match guards: `styleUrl`, `styleUrls` and `styles` are + // distinct fields and must never resolve to one another. + it('does not match `styleUrls:` when looking for the singular `styleUrl:`', () => { + const src = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleUrlFor(src, 'Foo')).toBeNull() + }) + + it('does not match the singular `styleUrl:` when looking for `styleUrls:`', () => { + const src = `@Component({ styleUrl: './a.css' })\nexport class Foo {}` + expect(locateStyleUrlsFor(src, 'Foo')).toBeNull() + }) + + it('does not match an inline `styles:` field as either url field', () => { + const src = `@Component({ styles: ['.a { color: red }'] })\nexport class Foo {}` + expect(locateStyleUrlsFor(src, 'Foo')).toBeNull() + expect(locateStyleUrlFor(src, 'Foo')).toBeNull() + }) + + it('does not match either url field as the inline `styles:` field', () => { + const urls = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + const url = `@Component({ styleUrl: './a.css' })\nexport class Foo {}` + expect(locateStylesFieldFor(urls, 'Foo')).toBeNull() + expect(locateStylesFieldFor(url, 'Foo')).toBeNull() + }) + + it('finds styleUrls when the decorator also has an inline styles field', () => { + const src = `@Component({ styles: ['.x{}'], styleUrls: ['./real.css'] })\nexport class Foo {}` + const range = locateStyleUrlsFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['./real.css']`) + }) + }) + + // The endpoint needs three answers, not two: a class with no styles must be + // served none, while a class whose styles cannot be read must fall back to + // the file-level list. Only a null return distinguishes them. + describe('locateStyleFieldsFor', () => { + // Read the literals out of a located field, for the tests that care + // about the value rather than the classification. + const literalsIn = (src: string, field: { kind: string; range?: [number, number] }) => { + expect(field.kind).toBe('literal') + return readStringLiterals(src, (field as { range: [number, number] }).range).literals + } + + it('returns null when className matches no decorator', () => { + const src = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Nope')).toBeNull() + }) + + it('reports both fields absent for a class that declares no styles', () => { + const src = `@Component({ template: '
' })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + it('reports a field whose value has no literal shape as unreadable', () => { + const src = `@Component({ styleUrls: STYLE_URLS })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('reports a singular `styleUrl` identifier value as unreadable, not absent', () => { + // The regression this guards: reporting it absent tells the caller + // "this class has no styles", which strips the component's CSS. + const src = `@Component({ styleUrl: STYLE_URL })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('reports inline and url ranges together when a class declares both', () => { + const src = `@Component({ styles: ['.x{}'], styleUrls: ['./real.css'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.inline)).toEqual(['.x{}']) + expect(literalsIn(src, fields.urls)).toEqual(['./real.css']) + }) + + it('falls back to the singular `styleUrl` for the urls member', () => { + const src = `@Component({ styleUrl: './solo.css' })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.urls)).toEqual(['./solo.css']) + expect(fields.inline).toEqual({ kind: 'absent' }) + }) + + it('reports the urls member unreadable when only the singular form is unreadable', () => { + const src = `@Component({ styleUrl: STYLE_URL, styles: ['.x{}'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(fields.urls).toEqual({ kind: 'unreadable' }) + expect(literalsIn(src, fields.inline)).toEqual(['.x{}']) + }) + + it('resolves each class separately in a multi-component file', () => { + const src = ` + @Component({ selector: 'a', styleUrls: ['./a.css'] }) + export class StyledComponent {} + @Component({ selector: 'b', template: '' }) + export class BareComponent {} + ` + const styled = locateStyleFieldsFor(src, 'StyledComponent')! + expect(literalsIn(src, styled.urls)).toEqual(['./a.css']) + expect(locateStyleFieldsFor(src, 'BareComponent')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + // Quoted keys are valid TS and the Rust extractor resolves them + // (verified: `'styleUrls'` and `"styleUrls"` both report their URL). + // Reading them as absent tells the caller the class declares no + // styles, which strips the component's CSS. + it('reads a single-quoted key the same as the bare form', () => { + const src = `@Component({ 'styleUrls': ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a double-quoted key the same as the bare form', () => { + const src = `@Component({ "styleUrls": ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a quoted singular `styleUrl` key', () => { + const src = `@Component({ 'styleUrl': './solo.css' })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./solo.css']) + }) + + it('reads a quoted inline `styles` key', () => { + const src = `@Component({ 'styles': ['.x{}'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.inline)).toEqual(['.x{}']) + }) + + it('keeps the cross-match guards for quoted keys', () => { + const urls = `@Component({ 'styleUrls': ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(urls, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const inline = `@Component({ 'styles': ['.x{}'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(inline, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + // A computed key hides the field name from this scan, but the Rust + // extractor resolves it (verified: `[K]: ['./computed.css']` reports + // the URL). "Absent" would be a lie, so the whole classification is + // unknown and the caller falls back. + it('reports both fields unreadable when a computed key is present', () => { + const src = `@Component({ [K]: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('reports both fields unreadable for a quoted key whose escape is malformed', () => { + // A decodable escape is resolved instead — see the `escaped keys` + // block. Only an escape this scan cannot decode leaves the field name + // unknown, and then "absent" would be a guess rather than an answer. + const src = `@Component({ 'style\\u00ZZrls': ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('does not promote a readable field to unreadable because of a computed key', () => { + // The visible field is still exactly what it says; only the fields + // this scan cannot see are unknown. + const src = `@Component({ [K]: 1, styleUrls: ['./a.css'] })\nexport class Foo {}` + const fields = locateStyleFieldsFor(src, 'Foo')! + expect(literalsIn(src, fields.urls)).toEqual(['./a.css']) + expect(fields.inline).toEqual({ kind: 'unreadable' }) + }) + + // A spread is the one unreadable form the Rust extractor ALSO drops + // (verified: `...BASE` reports no styleUrls). Both sides see nothing, + // so "absent" matches what the compiled component gets; falling back + // would hand this class its siblings' CSS. + it('leaves fields absent for a spread, which the compiler also drops', () => { + const src = `@Component({ ...BASE })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'absent' }, + inline: { kind: 'absent' }, + }) + }) + + // Shorthand style fields. Measured against the Rust extractor, which + // resolves a same-file string constant behind the singular `styleUrl` + // but drops the array-valued forms — so the two need opposite answers, + // for the same reason the spread above stays absent: match what the + // compiled component actually ends up with. + it('reports a shorthand singular `styleUrl` as unreadable, not absent', () => { + const src = `@Component({ template: '', styleUrl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('leaves a shorthand `styleUrls` absent, which the compiler also drops', () => { + const src = `@Component({ template: '', styleUrls })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('leaves a shorthand inline `styles` absent, which the compiler also drops', () => { + const src = `@Component({ template: '', styles })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.inline).toEqual({ kind: 'absent' }) + }) + + it('does not let an unrelated shorthand degrade a readable field', () => { + const src = `@Component({ selector, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + // The trap in accepting a bare key: an identifier used as a VALUE is not + // a shorthand property, and reading it as one would fall back on a field + // that is right there and readable. + it('does not mistake a style-named identifier used as a value for a shorthand', () => { + const src = `@Component({ selector: styleUrl, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('does not let an unrelated method degrade a readable field', () => { + const src = `@Component({ foo() { return 1 }, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + // A method or accessor named like a style field is not a style field: + // the compiler reads no styles from it either. + it('leaves a method named like a style field absent', () => { + const src = `@Component({ styleUrls() { return ['./a.css'] } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('leaves a getter named like a style field absent', () => { + const src = `@Component({ get styleUrl() { return './a.css' } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reads the real field past a setter named like a style field', () => { + const src = `@Component({ set styleUrl(v) {}, styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('does not read a style field nested in a deeper object', () => { + const src = `@Component({ data: { styleUrls: ['./deep.css'] } })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reads a field followed by a trailing comma', () => { + const src = `@Component({ styleUrls: ['./a.css'], })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('reads a shorthand style field that closes the object', () => { + // No trailing comma — the key is bounded by `}` rather than `,`. + const src = `@Component({ styleUrl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + // A value that merely STARTS with a literal does not denote it. Every + // form below was measured against the Rust extractor: it reports no + // styleUrls and compiles no styles for any of them. Returning the + // leading literal would hand the endpoint a stylesheet the component + // never had, and — being a confident answer — would skip the fallback + // that exists for exactly this case. + describe('a literal that is only the start of a larger expression', () => { + const urls = (field: string) => + locateStyleFieldsFor( + `const SUFFIX = '.s.css';\nconst MORE: string[] = ['./m.css'];\n@Component({ template: '', ${field} })\nexport class Foo {}`, + 'Foo', + )!.urls + const inline = (field: string) => + locateStyleFieldsFor( + `const EXTRA = '.b{}';\n@Component({ template: '', ${field} })\nexport class Foo {}`, + 'Foo', + )!.inline + + it('rejects a singular `styleUrl` concatenated with an identifier', () => { + expect(urls(`styleUrl: './a.css' + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a singular `styleUrl` concatenated with another literal', () => { + expect(urls(`styleUrl: './a.css' + './b.css'`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a `styleUrls` array concatenated with an identifier', () => { + expect(urls(`styleUrls: './a.css' + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects an inline `styles` value concatenated with an identifier', () => { + expect(inline(`styles: '.a{}' + EXTRA`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on a style literal', () => { + expect(urls(`styleUrl: './a.css'.replace('a', 'b')`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on a `styleUrls` array literal', () => { + expect(urls(`styleUrls: ['./a.css'].concat(MORE)`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a method call on an inline `styles` array literal', () => { + expect(inline(`styles: ['.a{}'].concat(MORE)`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as` assertion after a style literal', () => { + expect(urls(`styleUrl: './a.css' as string`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as const` assertion after a `styleUrls` array', () => { + expect(urls(`styleUrls: ['./a.css'] as const`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a TypeScript `as` assertion after an inline `styles` array', () => { + expect(inline(`styles: ['.a{}'] as string[]`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a non-null assertion after a style literal', () => { + expect(urls(`styleUrl: './a.css'!`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a `satisfies` clause after a style literal', () => { + expect(urls(`styleUrl: './a.css' satisfies string`)).toEqual({ kind: 'unreadable' }) + }) + + it('rejects a trailing expression hidden behind a comment', () => { + expect(urls(`styleUrl: './a.css' /* why */ + SUFFIX`)).toEqual({ kind: 'unreadable' }) + }) + + // The other side of the rule: a value the property really does end at + // stays readable, whether a comma, the object's brace or a comment + // closes it out. + it.each([ + [`styleUrl: './a.css'`, `the object's closing brace`], + [`styleUrl: './a.css',`, 'a trailing comma'], + [`styleUrl: './a.css' /* why */`, 'a block comment then the brace'], + [`styleUrl: './a.css' // why\n`, 'a line comment then the brace'], + [`styleUrls: ['./a.css']`, 'an array at the closing brace'], + [`styleUrls: ['./a.css'],`, 'an array with a trailing comma'], + ])('still reads %j, ended by %s', (field) => { + const src = `@Component({ template: '', ${field} })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + }) + + // Escaped identifier keys. `style\u0055rls` IS `styleUrls` to the + // TypeScript parser, and the Rust extractor resolves it (verified: it + // reports `./x.css`). Reading it as absent told the caller the class + // declares no styles, which strips the component's CSS. Decoding gives + // an exact per-class answer, where falling back would serve the + // file-level union — every sibling's CSS along with this class's own. + describe('escaped keys', () => { + it('decodes a \\uHHHH escape in a bare key', () => { + const src = `@Component({ style\\u0055rls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes a \\u{…} escape in a bare key', () => { + const src = `@Component({ style\\u{55}rls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes an escape at the first character of a bare key', () => { + const src = `@Component({ \\u0073tyleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('decodes an escaped singular `styleUrl` key', () => { + const src = `@Component({ style\\u0055rl: './solo.css' })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./solo.css']) + }) + + it('decodes an escaped inline `styles` key', () => { + const src = `@Component({ style\\u0073: ['.x{}'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.inline)).toEqual(['.x{}']) + }) + + it('decodes a quoted key carrying an escape', () => { + // A quoted key is a string literal, so it decodes by string rules. + const src = `@Component({ 'style\\u0055rls': ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + + it('treats an escaped shorthand singular `styleUrl` like the plain one', () => { + const src = `@Component({ template: '', style\\u0055rl })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('leaves an escaped shorthand `styleUrls` absent, as the compiler drops it', () => { + const src = `@Component({ template: '', style\\u0055rls })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('keeps the cross-match guards for decoded keys', () => { + const urls = `@Component({ style\\u0055rls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(urls, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const singular = `@Component({ style\\u0055rl: './a.css' })\nexport class Foo {}` + expect(locateStyleFieldsFor(singular, 'Foo')!.inline).toEqual({ kind: 'absent' }) + const inline = `@Component({ style\\u0073: ['.x{}'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(inline, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('does not match a decoded key that names something else', () => { + const src = `@Component({ style\\u0055rlsExtra: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('does not match a lookalike built from a non-ASCII letter', () => { + // Cyrillic \u0435 in place of `e` — a different identifier, and the + // compiler reports no styleUrls for it either. + const src = `@Component({ styl\u0435Urls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'absent' }) + }) + + it('reports a malformed escape in a key as unreadable, not absent', () => { + const src = `@Component({ style\\u00ZZrls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('reports a \\xHH escape in a key as unreadable — illegal in an identifier', () => { + const src = `@Component({ style\\x55rls: ['./a.css'] })\nexport class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')).toEqual({ + urls: { kind: 'unreadable' }, + inline: { kind: 'unreadable' }, + }) + }) + + it('does not let an escaped unrelated key degrade a readable field', () => { + const src = `@Component({ sel\\u0065ctor: 'a', styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(literalsIn(src, locateStyleFieldsFor(src, 'Foo')!.urls)).toEqual(['./a.css']) + }) + }) + }) + // ----------------------------------------------------------------- // Comment-aware scanning. Without this, the walker treats `'` in a // `// don't ...` line comment as opening a string literal that never // closes (real field missed), and a `// styles: [...]` line comment // or `/* styles: [...] */` block comment as a real field (wrong // range returned). + // + // `complete` reports whether every element was read. A caller acting + // on a partial list silently drops stylesheets. // ----------------------------------------------------------------- + describe('readStringLiterals', () => { + // Read the value of `styles:` from a one-component source, so these + // exercise the same path the extractors use. + const readOf = (value: string) => { + const src = `@Component({ styles: ${value} })\nclass Foo {}` + return readStringLiterals(src, locateStylesFieldFor(src, 'Foo')!) + } + const literalsOf = (value: string): string[] => readOf(value).literals + + it('reads every entry of an array in order', () => { + expect(readOf(`['./a.css', './b.css']`)).toEqual({ + literals: ['./a.css', './b.css'], + complete: true, + }) + }) + + it('reads a single-entry array', () => { + expect(literalsOf(`['./a.css']`)).toEqual(['./a.css']) + }) + + it('reads a bare string value as one entry', () => { + expect(readOf(`'./a.css'`)).toEqual({ literals: ['./a.css'], complete: true }) + }) + + it('reads mixed quote styles, including template literals', () => { + expect(literalsOf('[\'./a.css\', "./b.css", `./c.css`]')).toEqual([ + './a.css', + './b.css', + './c.css', + ]) + }) + + it('reports an empty array as complete, not unknown', () => { + // `styles: []` is a definitive answer: this class has no styles. + expect(readOf(`[]`)).toEqual({ literals: [], complete: true }) + }) + + it('decodes hex and unicode escapes to the value they denote', () => { + // The Rust extractor reports the cooked value; a raw read would name a + // path that does not exist. + expect(literalsOf(String.raw`['.\x2fa.css', '\u{2e}/b.css']`)).toEqual(['./a.css', './b.css']) + }) + + it('decodes the standard single-character escapes', () => { + expect(literalsOf(String.raw`['a\nb\tc\rd\be\ff\vg']`)).toEqual(['a\nb\tc\rd\be\ff\vg']) + }) + + it('decodes a backslash escape to one backslash', () => { + expect(literalsOf(String.raw`['a\\b']`)).toEqual([String.raw`a\b`]) + }) + + it('decodes an unrecognized escape to the character itself', () => { + // `\q` is a NonEscapeCharacter: it denotes `q`, which is what the Rust + // extractor reports too. + expect(literalsOf(String.raw`['./a\qb.css']`)).toEqual(['./aqb.css']) + }) + + it('drops a line continuation', () => { + expect(literalsOf("['a\\\nb']")).toEqual(['ab']) + }) + + it('reports a truncated unicode escape as incomplete', () => { + expect(readOf(String.raw`['./a\u12']`)).toEqual({ literals: [], complete: false }) + }) + + it('reports a malformed hex escape as incomplete', () => { + expect(readOf(String.raw`['./a\xZZ']`)).toEqual({ literals: [], complete: false }) + }) + + it('reports a legacy octal escape as incomplete', () => { + // Illegal in a module; guessing a value would be worse than falling back. + expect(readOf(String.raw`['./a\101']`)).toEqual({ literals: [], complete: false }) + }) + + it('leaves a literal with no escapes byte for byte unchanged', () => { + const css = `a::before { content: "x"; } [data-x="y"] { color: red; }` + expect(literalsOf(`['${css}']`)).toEqual([css]) + }) + + it('decodes an escaped quote inside a literal', () => { + // The literal denotes `it's.css`, which is what the Rust extractor + // reports and what has to be resolved against the filesystem. + expect(literalsOf(`['it\\'s.css']`)).toEqual([`it's.css`]) + }) + + it('ignores an apostrophe inside a block comment before an entry', () => { + expect(literalsOf(`[/* don't drop this */ './a.css']`)).toEqual(['./a.css']) + }) + + it('ignores an apostrophe inside a line comment before an entry', () => { + expect(literalsOf(`[\n // it's here\n './a.css',\n]`)).toEqual(['./a.css']) + }) + + it('ignores a comment between two entries', () => { + expect(literalsOf(`['./a.css', /* don't */ './b.css']`)).toEqual(['./a.css', './b.css']) + }) + + it('reports a non-literal entry as incomplete', () => { + // The literal alongside it is not a partial answer to act on: acting + // on it would drop the stylesheet the constant resolves to. + expect(readOf(`[SOME_CONST, './a.css']`).complete).toBe(false) + }) + + // ----------------------------------------------------------------- + // Spread elements (`...X`) and elisions (holes). + // + // `extract_string_array` asks every array element for `as_expression()`, + // and `ArrayExpressionElement::is_expression()` enumerates neither + // `SpreadElement` nor `Elision`. Both are therefore dropped BEFORE the + // value resolver runs, for every possible program — the const table is + // never consulted for them. Dropping them here keeps the array complete + // and matches what the component actually compiles to; calling it + // unknown would hand the class its siblings' stylesheets instead. + // + // Measured against the Rust extractor: + // [...SHARED, './own.css'] -> ["./own.css"] + // [...SHARED] -> [] + // [S, './own.css'] (S a const) -> ["./shared.css", "./own.css"] + // + // Every OTHER element IS an expression the resolver may fold from + // constants this scan cannot read, so those still mark the array + // unknown. Same reasoning as the decorator-level spread in + // `hasUnreadableKey`; the two now agree. + // ----------------------------------------------------------------- + it('drops a leading spread and reads the rest, like the compiler', () => { + expect(readOf(`[...SHARED, './a.css']`)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('drops a trailing spread and reads the rest', () => { + expect(readOf(`['./a.css', ...SHARED]`)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('reads a spread-only array as declaring nothing', () => { + expect(readOf(`[...SHARED]`)).toEqual({ literals: [], complete: true }) + }) + + it('reads an array of two spreads as declaring nothing', () => { + expect(readOf(`[...S1, ...S2]`)).toEqual({ literals: [], complete: true }) + }) + + it('drops a spread of an inline array literal', () => { + // The compiler drops the element whole, so a literal INSIDE the spread + // is not part of this component's styles. + expect(readOf(`[...['./shared.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal used as a computed member inside a spread', () => { + expect(readOf(`[...obj['./leak.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal passed as a call argument inside a spread', () => { + expect(readOf(`[...f('./leak.css'), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('never leaks a literal inside an object literal in a spread', () => { + expect(readOf(`[...Object.values({ k: './leak.css' }), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('keeps a comma inside parens from ending the spread element', () => { + expect(readOf(`[...f(a, b), './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('keeps a comma inside a nested array from ending the spread element', () => { + expect(readOf(`[...['./x.css', './y.css'], './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('ignores a block comment inside a spread', () => { + expect(readOf(`[.../* , './leak.css' */ SHARED, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('ignores an apostrophe and a comma inside a line comment in a spread', () => { + expect(readOf(`[...SHARED // don't , './leak.css'\n, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('skips an interpolated template literal inside a spread', () => { + // The `${...}` pushes a brace context, so the comma inside the template + // does not end the element. + expect(readOf("[...tag`${DIR}/a, b`, './own.css']")).toEqual({ + literals: ['./own.css'], + complete: true, + }) + }) + + it('drops a spread in a `styleUrls` array too', () => { + const src = `@Component({ styleUrls: [...SHARED, './a.css'] })\nclass Foo {}` + expect(readStringLiterals(src, locateStyleUrlsFor(src, 'Foo')!)).toEqual({ + literals: ['./a.css'], + complete: true, + }) + }) + + it('drops a spread in an inline `styles` array', () => { + expect(readOf(`[...SHARED_INLINE, ':host{}']`)).toEqual({ + literals: [':host{}'], + complete: true, + }) + }) + + it('reports an unterminated string inside a spread as incomplete', () => { + // The quote never closes, so where the element ends is unknowable. + // A real file cannot produce this range (the locator needs a balanced + // `]`), but the scan must refuse to guess rather than run off. + const src = `[...f('./leak.css)]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('reports an unclosed paren inside a spread as incomplete', () => { + const src = `[...f(a]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('reports a spread ending in an escape as incomplete', () => { + // The trailing escape consumes the array's own `]`, so the scan + // overshoots the range: incomplete, not a guess. + const src = String.raw`[...'a\]` + expect(readStringLiterals(src, [0, src.length - 1])).toEqual({ + literals: [], + complete: false, + }) + }) + + it('does not treat a leading decimal point as a spread', () => { + expect(readOf(`[.5, './a.css']`).complete).toBe(false) + }) + + it('does not treat two dots as a spread', () => { + expect(readOf(`[..X, './a.css']`).complete).toBe(false) + }) + + it('does not treat a member access as a spread', () => { + expect(readOf(`[STYLES.a, './a.css']`).complete).toBe(false) + }) + + it('does not treat an optional chain as a spread', () => { + expect(readOf(`[STYLES?.a, './a.css']`).complete).toBe(false) + }) + + it('still reports a bare identifier element as incomplete, unlike a spread', () => { + // Measured: the Rust extractor DOES fold `S` from the const table, so + // the literals gathered here are not the component's full style list. + expect(readOf(`[S, './own.css']`)).toEqual({ + literals: ['./own.css'], + complete: false, + }) + }) + + it('reads across a leading elision, which the compiler also drops', () => { + expect(readOf(`[, './a.css']`)).toEqual({ literals: ['./a.css'], complete: true }) + }) + + it('reads across an elision between two entries', () => { + expect(readOf(`['./a.css', , './b.css']`)).toEqual({ + literals: ['./a.css', './b.css'], + complete: true, + }) + }) + + it('reports an interpolated template literal as incomplete', () => { + // The raw slice is `${DIR}/a.css`, not a real path. The Rust extractor + // folds it; this scan cannot. + expect(readOf('[`${DIR}/a.css`]').complete).toBe(false) + }) + + it('reports a bare interpolated template literal as incomplete', () => { + expect(readOf('`${DIR}/a.css`').complete).toBe(false) + }) + + it('treats an escaped `${` in a template literal as ordinary text', () => { + // `hasInterpolation` reads the odd backslash as an escape, so the + // literal is complete; decoding then resolves `\$` to `$`, leaving the + // `${…}` as the literal text it denotes. The two must agree. + expect(readOf('[`\\${NOT_INTERPOLATED}.css`]')).toEqual({ + literals: ['${NOT_INTERPOLATED}.css'], + complete: true, + }) + }) + + it('treats `${` inside a quoted string as ordinary text', () => { + expect(readOf(`['\${DIR}/a.css']`)).toEqual({ + literals: ['${DIR}/a.css'], + complete: true, + }) + }) + + it('stops at an unterminated literal and reports incomplete', () => { + // The locator bounds the range, so the unterminated entry is dropped. + const src = `@Component({ styles: ['./a.css', './b.css] })\nclass Foo {}` + const range = locateStylesFieldFor(src, 'Foo') + if (range) { + const read = readStringLiterals(src, range) + expect(read.literals).toEqual(['./a.css']) + expect(read.complete).toBe(false) + } + }) + }) + describe('comment handling in @Component args', () => { it('does not get stuck on an apostrophe inside a line comment', () => { const src = `@Component({ @@ -409,6 +1275,131 @@ class Foo {}` const sRange = locateStylesFieldFor(src, 'Foo')! expect(src.slice(sRange[0], sRange[1] + 1)).toBe(`['real']`) }) + + // A comment may sit anywhere inside a style field declaration, and the + // Rust extractor reads straight past it. Every placement below was + // measured against `extractComponentUrls`, which reports `./x.css` for + // each one. Reading any of them as absent would tell the caller the + // class declares no styles, which strips the component's CSS. + describe('comment placement within a style field declaration', () => { + const urlsOf = (src: string) => { + const field = locateStyleFieldsFor(src, 'Foo')!.urls + expect(field.kind).toBe('literal') + return readStringLiterals(src, (field as { range: [number, number] }).range).literals + } + const decorator = (field: string) => + `@Component({ template: '', ${field} })\nexport class Foo {}` + + it('reads a field with a block comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* why */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a line comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls // why\n: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a comment between the colon and the value', () => { + expect(urlsOf(decorator(`styleUrls: /* why */ ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with a comment before the key', () => { + expect(urlsOf(decorator(`/* why */ styleUrls: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a field with two comments between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* a */ /* b */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads a quoted key with a comment before the colon', () => { + expect(urlsOf(decorator(`'styleUrls' /* why */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('reads the singular `styleUrl` with a comment before the colon', () => { + expect(urlsOf(decorator(`styleUrl /* why */: './x.css'`))).toEqual(['./x.css']) + }) + + it('reads the singular `styleUrl` with a comment after the colon', () => { + expect(urlsOf(decorator(`styleUrl: /* why */ './x.css'`))).toEqual(['./x.css']) + }) + + it('reads an array whose first literal follows a comment', () => { + expect(urlsOf(decorator(`styleUrls: [/* why */ './x.css']`))).toEqual(['./x.css']) + }) + + it('reads an array whose first literal follows a line comment', () => { + expect(urlsOf(decorator(`styleUrls: [// why\n './x.css']`))).toEqual(['./x.css']) + }) + + it('reads an array with a comment before the separating comma', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css' /* why */, './y.css']`))).toEqual([ + './x.css', + './y.css', + ]) + }) + + it('reads an array with a comment after the separating comma', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css', /* why */ './y.css']`))).toEqual([ + './x.css', + './y.css', + ]) + }) + + it('reads an array with a comment after the last literal', () => { + expect(urlsOf(decorator(`styleUrls: ['./x.css' /* why */]`))).toEqual(['./x.css']) + }) + + it('reads an inline `styles` field with a comment before the colon', () => { + const src = decorator(`styles /* why */: ['.a{}']`) + const field = locateStyleFieldsFor(src, 'Foo')!.inline + expect(field.kind).toBe('literal') + expect( + readStringLiterals(src, (field as { range: [number, number] }).range).literals, + ).toEqual(['.a{}']) + }) + + // An array holding only a comment is still a syntactically valid empty + // array: a definite "no styles", not an unreadable value. + it('reads an array holding only a comment as definitively empty', () => { + const src = decorator(`styleUrls: [/* why */]`) + const field = locateStyleFieldsFor(src, 'Foo')!.urls + expect(field.kind).toBe('literal') + expect( + readStringLiterals(src, (field as { range: [number, number] }).range).literals, + ).toEqual([]) + }) + + // Comment *contents* must never be mistaken for structure. + it('ignores a colon, comma and brackets inside the comment', () => { + expect(urlsOf(decorator(`styleUrls /* a: b, c ] [ */: ['./x.css']`))).toEqual(['./x.css']) + }) + + it('ignores a decoy style field inside the comment', () => { + expect(urlsOf(decorator(`styleUrls /* styleUrl: './fake.css' */: ['./x.css']`))).toEqual([ + './x.css', + ]) + }) + + it('ignores an apostrophe inside a comment between the key and the colon', () => { + expect(urlsOf(decorator(`styleUrls /* don't */: ['./x.css']`))).toEqual(['./x.css']) + }) + + // A comment must not turn a shorthand back into "absent": the compiler + // resolves a same-file constant behind the singular `styleUrl` + // (verified: `./sh.css`), so this class must still reach the fallback. + it('keeps a shorthand `styleUrl` unreadable when a comment precedes the closing brace', () => { + const src = `const styleUrl = './sh.css'; +@Component({ template: '', styleUrl /* why */ }) +export class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + + it('keeps a shorthand `styleUrl` unreadable when a comment precedes the comma', () => { + const src = `const styleUrl = './sh.css'; +@Component({ template: '', styleUrl /* why */, selector: 'a' }) +export class Foo {}` + expect(locateStyleFieldsFor(src, 'Foo')!.urls).toEqual({ kind: 'unreadable' }) + }) + }) }) // ----------------------------------------------------------------- diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index cc57a00d0..b95808c2b 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, ModuleNode, HmrContext } from 'vite' -import { normalizePath } from 'vite' +import { normalizePath, resolveConfig } from 'vite' import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest' import { angular } from '../vite-plugin/index.js' @@ -1204,6 +1204,20 @@ describe('@ng/component endpoint resolves the template per class', () => { ) } + // The component-file branch queues `pendingHmrUpdates` under `ctx.file` + // verbatim, and the endpoint looks the id up verbatim. Asserting the queued + // id here turns a spelling mismatch into a named failure instead of an + // unexplained empty body — the difference only shows up on Windows, where + // a normalized path and a `join()` path are different strings. + function expectDispatched(mockServer: any, componentId: string) { + const ids = mockServer._wsMessages + .filter((m: any) => m?.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(ids, 'expected the HMR dispatch to use the requested path spelling').toContain( + componentId, + ) + } + function getMiddleware(mockServer: any) { const middleware = (mockServer.middlewares.use as ReturnTypefirst
', + styleUrls: ['./ps-first.component.css'], + }) + export class FirstComponent {} + @Component({ + selector: 'app-ps-second', + template: 'second
', + styleUrls: ['./ps-second.component.css'], + }) + export class SecondComponent {} + ` + writeFileSync(multiPath, source) + await transformSource(plugin, source, multiPath) + + // Edit the SECOND component's stylesheet; the fan-out queues both classes. + writeFileSync(secondCssPath, '.PS_SECOND_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(secondCssPath), + [{ id: normalizePath(secondCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + // SecondComponent's update module must carry only its OWN stylesheet, not + // the union of every styleUrl declared in the file. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${multiPath}@SecondComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SECOND_MARKER') + expect(body).not.toContain('PS_FIRST_MARKER') + }) + + it('serves the singular `styleUrl` of the requested class', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const soloCssPath = join(appDir, 'ps-solo.component.css') + const otherCssPath = join(appDir, 'ps-other.component.css') + const singularPath = join(appDir, 'ps-singular.component.ts') + writeFileSync(soloCssPath, '.PS_SOLO_MARKER { color: red; }') + writeFileSync(otherCssPath, '.PS_OTHER_MARKER { color: blue; }') + + // `styleUrl` (singular, Angular 17+) is a bare string, not an array. + // It is declared SECOND here so the file-level list does not happen to + // start with it. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-other', + template: 'other
', + styleUrl: './ps-other.component.css', + }) + export class OtherComponent {} + @Component({ + selector: 'app-ps-solo', + template: 'solo
', + styleUrl: './ps-solo.component.css', + }) + export class SoloComponent {} + ` + writeFileSync(singularPath, source) + await transformSource(plugin, source, singularPath) + + writeFileSync(soloCssPath, '.PS_SOLO_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(soloCssPath), + [{ id: normalizePath(soloCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${singularPath}@SoloComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SOLO_MARKER') + expect(body).not.toContain('PS_OTHER_MARKER') + }) + + it('serves the inline styles of a class whose sibling uses a styleUrl', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const extCssPath = join(appDir, 'ps-ext.component.css') + const mixedPath = join(appDir, 'ps-mixed.component.ts') + writeFileSync(extCssPath, '.PS_EXT_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-ext', + template: 'ext
', + styleUrls: ['./ps-ext.component.css'], + }) + export class ExtComponent {} + @Component({ + selector: 'app-ps-inline', + template: 'inline
', + styles: ['.PS_INLINE_MARKER { color: blue; }'], + }) + export class InlineComponent {} + ` + writeFileSync(mixedPath, source) + await transformSource(plugin, source, mixedPath) + + // Edit only the inline styles; the strip-equality branch queues both + // classes in the file. + const edited = source.replace('color: blue', 'color: green') + writeFileSync(mixedPath, edited) + const ctx = createMockHmrContext(mixedPath, [{ id: mixedPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + expectDispatched(mockServer, `${mixedPath}@InlineComponent`) + + // InlineComponent must get its OWN inline styles — a sibling's styleUrl + // making the FILE-level list non-empty must not shadow the inline branch. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixedPath}@InlineComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INLINE_MARKER') + expect(body).not.toContain('PS_EXT_MARKER') + }) + it('serves the styleUrls of a class whose array carries a comment', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const realCssPath = join(appDir, 'ps-commented.component.css') + const commentedPath = join(appDir, 'ps-commented.component.ts') + writeFileSync(realCssPath, '.PS_COMMENTED_MARKER { color: red; }') + + // An apostrophe inside a comment in the array body must not be mistaken + // for a string delimiter, which would swallow the real entry. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-commented', + template: 'commented
', + styleUrls: [ + /* don't drop this */ + './ps-commented.component.css', + ], + }) + export class CommentedComponent {} + ` + writeFileSync(commentedPath, source) + await transformSource(plugin, source, commentedPath) + + writeFileSync(realCssPath, '.PS_COMMENTED_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(realCssPath), + [{ id: normalizePath(realCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${commentedPath}@CommentedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_COMMENTED_MARKER') + }) + + it('serves the inline styles of a class whose array carries a comment', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const inlineCommentPath = join(appDir, 'ps-inline-comment.component.ts') + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-inline-comment', + template: 'inline
', + styles: [ + /* it's fine */ + '.PS_INLINE_COMMENT_MARKER { color: red; }', + ], + }) + export class InlineCommentComponent {} + ` + writeFileSync(inlineCommentPath, source) + await transformSource(plugin, source, inlineCommentPath) + + const edited = source.replace('color: red', 'color: green') + writeFileSync(inlineCommentPath, edited) + // Editing the `.ts` itself takes the component-file branch, which keys + // `componentsByFile` and `pendingHmrUpdates` on `ctx.file` verbatim. Use + // the same spelling here, for `transform` above, and for the endpoint + // request below: on Windows a normalized path and a `join()` path differ + // as strings, and the lookup would miss. Real Vite hands both hooks the + // same normalized spelling, so only a test can mix them. + const ctx = createMockHmrContext(inlineCommentPath, [{ id: inlineCommentPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + expectDispatched(mockServer, `${inlineCommentPath}@InlineCommentComponent`) + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${inlineCommentPath}@InlineCommentComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INLINE_COMMENT_MARKER') + }) + it('serves no styles to a class that declares none, even beside a styled sibling', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const styledCssPath = join(appDir, 'ps-bare-styled.component.css') + const barePath = join(appDir, 'ps-bare.component.ts') + writeFileSync(styledCssPath, '.PS_BARE_SIBLING_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-styled', + template: 'styled
', + styleUrls: ['./ps-bare-styled.component.css'], + }) + export class StyledComponent {} + @Component({ + selector: 'app-ps-bare', + template: 'bare
', + }) + export class BareComponent {} + ` + writeFileSync(barePath, source) + await transformSource(plugin, source, barePath) + + writeFileSync(styledCssPath, '.PS_BARE_SIBLING_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(styledCssPath), + [{ id: normalizePath(styledCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + // BareComponent declares no styles at all: it must get NONE, not the + // file-level union of its sibling's scoped CSS. + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${barePath}@BareComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_BARE_SIBLING_MARKER') + expect(body).not.toContain('styles:') + }) + + it('serves both inline styles and styleUrls when a class declares both', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const extCssPath = join(appDir, 'ps-merge-ext.component.css') + const mergePath = join(appDir, 'ps-merge.component.ts') + writeFileSync(extCssPath, '.PS_MERGE_EXTERNAL_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-merge', + template: 'merge
', + styles: ['.PS_MERGE_INLINE_MARKER { color: blue; }'], + styleUrls: ['./ps-merge-ext.component.css'], + }) + export class MergeComponent {} + ` + writeFileSync(mergePath, source) + await transformSource(plugin, source, mergePath) + + writeFileSync(extCssPath, '.PS_MERGE_EXTERNAL_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(extCssPath), + [{ id: normalizePath(extCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mergePath}@MergeComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_MERGE_INLINE_MARKER') + expect(body).toContain('PS_MERGE_EXTERNAL_MARKER') + // Angular's order: the decorator's own inline `styles` first, then the + // resolved `styleUrl(s)` content appended (see `resolve_styles`). + expect(body.indexOf('PS_MERGE_INLINE_MARKER')).toBeLessThan( + body.indexOf('PS_MERGE_EXTERNAL_MARKER'), + ) + }) + + it('falls back to the file-level styleUrls when a class uses a non-literal entry', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const constCssPath = join(appDir, 'ps-const.component.css') + const constPath = join(appDir, 'ps-const.component.ts') + writeFileSync(constCssPath, '.PS_CONST_MARKER { color: red; }') + + // The URL comes from a const, so the per-class locator finds the field but + // reads no string literal out of it. That must fall back to the file-level + // list (which the Rust extractor DOES fold), not serve an empty style set. + const source = ` + import { Component } from '@angular/core'; + const STYLE_URL = './ps-const.component.css'; + @Component({ + selector: 'app-ps-const', + template: 'const
', + styleUrls: [STYLE_URL], + }) + export class ConstComponent {} + ` + writeFileSync(constPath, source) + await transformSource(plugin, source, constPath) + + writeFileSync(constCssPath, '.PS_CONST_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(constCssPath), + [{ id: normalizePath(constCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${constPath}@ConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_CONST_MARKER') + }) + + it('falls back when the singular `styleUrl` is a same-file constant', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-singconst-own.component.css') + const sibCssPath = join(appDir, 'ps-singconst-sib.component.css') + const singConstPath = join(appDir, 'ps-singconst.component.ts') + writeFileSync(ownCssPath, '.PS_SINGCONST_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SINGCONST_SIB_MARKER { color: red; }') + + // The Rust extractor folds the const (verified: it reports ./own.css), + // but the text scan cannot. That is "unknown", not "no styles" — serving + // an empty set would strip this component's CSS entirely. + const source = ` + import { Component } from '@angular/core'; + const STYLE_URL = './ps-singconst-own.component.css'; + @Component({ + selector: 'app-ps-singconst', + template: 'const
', + styleUrl: STYLE_URL, + }) + export class SingConstComponent {} + @Component({ + selector: 'app-ps-singconst-sib', + template: 'sib
', + styleUrls: ['./ps-singconst-sib.component.css'], + }) + export class SingConstSiblingComponent {} + ` + writeFileSync(singConstPath, source) + await transformSource(plugin, source, singConstPath) + + writeFileSync(ownCssPath, '.PS_SINGCONST_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${singConstPath}@SingConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SINGCONST_OWN_MARKER') + }) + + it('serves the own stylesheet of a class whose `styleUrls` key is escaped', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-esckey-own.component.css') + const sibCssPath = join(appDir, 'ps-esckey-sib.component.css') + const escKeyPath = join(appDir, 'ps-esckey.component.ts') + writeFileSync(ownCssPath, '.PS_ESCKEY_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_ESCKEY_SIB_MARKER { color: red; }') + + // `style\u0055rls` IS `styleUrls` to the TypeScript parser, and the Rust + // extractor resolves it. Reading the key as absent said "this class has + // no styles" and served none, stripping the component's CSS. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-esckey', + template: 'esc
', + style\\u0055rls: ['./ps-esckey-own.component.css'], + }) + export class EscKeyComponent {} + @Component({ + selector: 'app-ps-esckey-sib', + template: 'sib
', + styleUrls: ['./ps-esckey-sib.component.css'], + }) + export class EscKeySiblingComponent {} + ` + writeFileSync(escKeyPath, source) + await transformSource(plugin, source, escKeyPath) + + writeFileSync(ownCssPath, '.PS_ESCKEY_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${escKeyPath}@EscKeyComponent`) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${escKeyPath}@EscKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_ESCKEY_OWN_MARKER') + // Decoding the key gives an exact per-class answer, so unlike the + // constant-valued fallback above, the sibling's CSS does not ride along. + expect(body).not.toContain('PS_ESCKEY_SIB_MARKER') + }) + + it('serves no styles for an explicitly empty `styleUrls` array', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = join(appDir, 'ps-emptyurls-sib.component.css') + const emptyUrlsPath = join(appDir, 'ps-emptyurls.component.ts') + writeFileSync(sibCssPath, '.PS_EMPTYURLS_SIB_MARKER { color: red; }') + + // `styleUrls: []` is valid and definitive: this class has no styles. It + // must not inherit the file-level union. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-emptyurls', + template: 'empty
', + styleUrls: [], + }) + export class EmptyUrlsComponent {} + @Component({ + selector: 'app-ps-emptyurls-sib', + template: 'sib
', + styleUrls: ['./ps-emptyurls-sib.component.css'], + }) + export class EmptyUrlsSiblingComponent {} + ` + writeFileSync(emptyUrlsPath, source) + await transformSource(plugin, source, emptyUrlsPath) + + writeFileSync(sibCssPath, '.PS_EMPTYURLS_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${emptyUrlsPath}@EmptyUrlsComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_EMPTYURLS_SIB_MARKER') + }) + + it('serves no styles for an explicitly empty inline `styles` array', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = join(appDir, 'ps-emptyinline-sib.component.css') + const emptyInlinePath = join(appDir, 'ps-emptyinline.component.ts') + writeFileSync(sibCssPath, '.PS_EMPTYINLINE_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-emptyinline', + template: 'empty
', + styles: [], + }) + export class EmptyInlineComponent {} + @Component({ + selector: 'app-ps-emptyinline-sib', + template: 'sib
', + styleUrls: ['./ps-emptyinline-sib.component.css'], + }) + export class EmptyInlineSiblingComponent {} + ` + writeFileSync(emptyInlinePath, source) + await transformSource(plugin, source, emptyInlinePath) + + writeFileSync(sibCssPath, '.PS_EMPTYINLINE_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${emptyInlinePath}@EmptyInlineComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_EMPTYINLINE_SIB_MARKER') + }) + + it('falls back when a `styleUrls` array mixes a constant with a literal', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const constCssPath = join(appDir, 'ps-mixed-const.component.css') + const litCssPath = join(appDir, 'ps-mixed-lit.component.css') + const mixedPath = join(appDir, 'ps-mixed.component.ts') + writeFileSync(constCssPath, '.PS_MIXED_CONST_MARKER { color: red; }') + writeFileSync(litCssPath, '.PS_MIXED_LIT_MARKER { color: red; }') + + // One entry is a const the text scan cannot read. Returning just the + // literal would silently drop a stylesheet — the array is unknown, not + // partially known. + const source = ` + import { Component } from '@angular/core'; + const MIXED_STYLE = './ps-mixed-const.component.css'; + @Component({ + selector: 'app-ps-mixed', + template: 'mixed
', + styleUrls: [MIXED_STYLE, './ps-mixed-lit.component.css'], + }) + export class MixedComponent {} + ` + writeFileSync(mixedPath, source) + await transformSource(plugin, source, mixedPath) + + writeFileSync(litCssPath, '.PS_MIXED_LIT_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(litCssPath), + [{ id: normalizePath(litCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixedPath}@MixedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_MIXED_LIT_MARKER') + expect(body).toContain('PS_MIXED_CONST_MARKER') + }) + + it('falls back when a `styleUrl` template literal is interpolated', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const interpCssPath = join(appDir, 'ps-interp.component.css') + const interpPath = join(appDir, 'ps-interp.component.ts') + writeFileSync(interpCssPath, '.PS_INTERP_MARKER { color: red; }') + + // The Rust extractor folds this to ./ps-interp.component.css; the raw + // slice `${DIR}/ps-interp.component.css` is not a real path. + const source = ` + import { Component } from '@angular/core'; + const DIR = '.'; + @Component({ + selector: 'app-ps-interp', + template: 'interp
', + styleUrl: \`\${DIR}/ps-interp.component.css\`, + }) + export class InterpComponent {} + ` + writeFileSync(interpPath, source) + await transformSource(plugin, source, interpPath) + + writeFileSync(interpCssPath, '.PS_INTERP_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(interpCssPath), + [{ id: normalizePath(interpCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${interpPath}@InterpComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_INTERP_MARKER') + }) + + // Quoted metadata keys are valid TS, and the Rust extractor resolves + // them (verified: `'styleUrls'` reports its URL). Reading the key as + // absent makes this class look styleless, which serves it nothing. + it('serves the own styleUrls of a class whose key is single-quoted', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const quotedCssPath = join(appDir, 'ps-quoted-own.component.css') + const quotedSibCssPath = join(appDir, 'ps-quoted-sib.component.css') + const quotedPath = join(appDir, 'ps-quoted.component.ts') + writeFileSync(quotedCssPath, '.PS_QUOTED_OWN_MARKER { color: red; }') + writeFileSync(quotedSibCssPath, '.PS_QUOTED_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-quoted', + template: 'quoted
', + 'styleUrls': ['./ps-quoted-own.component.css'], + }) + export class QuotedKeyComponent {} + @Component({ + selector: 'app-ps-quoted-sib', + template: 'sib
', + styleUrls: ['./ps-quoted-sib.component.css'], + }) + export class QuotedKeySiblingComponent {} + ` + writeFileSync(quotedPath, source) + await transformSource(plugin, source, quotedPath) + + writeFileSync(quotedCssPath, '.PS_QUOTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(quotedCssPath), + [{ id: normalizePath(quotedCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${quotedPath}@QuotedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_QUOTED_OWN_MARKER') + expect(body).not.toContain('PS_QUOTED_SIB_MARKER') + }) + + it('serves the own styleUrls of a class whose key is double-quoted', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const dqCssPath = join(appDir, 'ps-dquoted-own.component.css') + const dqSibCssPath = join(appDir, 'ps-dquoted-sib.component.css') + const dqPath = join(appDir, 'ps-dquoted.component.ts') + writeFileSync(dqCssPath, '.PS_DQUOTED_OWN_MARKER { color: red; }') + writeFileSync(dqSibCssPath, '.PS_DQUOTED_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-dquoted', + template: 'dquoted
', + "styleUrls": ['./ps-dquoted-own.component.css'], + }) + export class DQuotedKeyComponent {} + @Component({ + selector: 'app-ps-dquoted-sib', + template: 'sib
', + styleUrls: ['./ps-dquoted-sib.component.css'], + }) + export class DQuotedKeySiblingComponent {} + ` + writeFileSync(dqPath, source) + await transformSource(plugin, source, dqPath) + + writeFileSync(dqCssPath, '.PS_DQUOTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(dqCssPath), + [{ id: normalizePath(dqCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${dqPath}@DQuotedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_DQUOTED_OWN_MARKER') + expect(body).not.toContain('PS_DQUOTED_SIB_MARKER') + }) + + // A computed key hides the field from this scan while the Rust extractor + // still resolves it, so the class must fall back rather than be served + // nothing — the file-level list carries its stylesheet. + it('falls back when a style field is declared under a computed key', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const computedCssPath = join(appDir, 'ps-computed-own.component.css') + const computedPath = join(appDir, 'ps-computed.component.ts') + writeFileSync(computedCssPath, '.PS_COMPUTED_OWN_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + const K = 'styleUrls'; + @Component({ + selector: 'app-ps-computed', + template: 'computed
', + [K]: ['./ps-computed-own.component.css'], + }) + export class ComputedKeyComponent {} + ` + writeFileSync(computedPath, source) + await transformSource(plugin, source, computedPath) + + writeFileSync(computedCssPath, '.PS_COMPUTED_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(computedCssPath), + [{ id: normalizePath(computedCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${computedPath}@ComputedKeyComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_COMPUTED_OWN_MARKER') + }) + + // A spread is the one unreadable form the compiler ALSO drops, so + // "declares no styles" is what the compiled component actually gets. + // Falling back here would hand this class its sibling's CSS instead. + it('serves no styles for a spread-only decorator, matching the compiler', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const spreadSibCssPath = join(appDir, 'ps-spread-sib.component.css') + const spreadPath = join(appDir, 'ps-spread.component.ts') + writeFileSync(spreadSibCssPath, '.PS_SPREAD_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + const BASE = { selector: 'app-ps-spread' }; + @Component({ + template: 'spread
', + ...BASE, + }) + export class SpreadComponent {} + @Component({ + selector: 'app-ps-spread-sib', + template: 'sib
', + styleUrls: ['./ps-spread-sib.component.css'], + }) + export class SpreadSiblingComponent {} + ` + writeFileSync(spreadPath, source) + await transformSource(plugin, source, spreadPath) + + writeFileSync(spreadSibCssPath, '.PS_SPREAD_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(spreadSibCssPath), + [{ id: normalizePath(spreadSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${spreadPath}@SpreadComponent`, + ) + expect(body).not.toBe('') + expect(body).not.toContain('PS_SPREAD_SIB_MARKER') + }) + + // A spread INSIDE a `styleUrls` array is dropped by the compiler the same + // way, before any constant is resolved — so this class compiles to exactly + // the one path it spells out. Reading the array as unknown would fall back + // to the file-level list and serve it its sibling's CSS as well. + it('serves only the spelled-out entry of a styleUrls array with a spread', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-spreadarr-own.component.css') + const sibCssPath = join(appDir, 'ps-spreadarr-sib.component.css') + const spreadArrPath = join(appDir, 'ps-spreadarr.component.ts') + writeFileSync(ownCssPath, '.PS_SPREADARR_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SPREADARR_SIB_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + const SHARED = []; + @Component({ + selector: 'app-ps-spreadarr', + template: 'own
', + styleUrls: [...SHARED, './ps-spreadarr-own.component.css'], + }) + export class SpreadArrayComponent {} + @Component({ + selector: 'app-ps-spreadarr-sib', + template: 'sib
', + styleUrls: ['./ps-spreadarr-sib.component.css'], + }) + export class SpreadArraySiblingComponent {} + ` + writeFileSync(spreadArrPath, source) + await transformSource(plugin, source, spreadArrPath) + + // Edit the SIBLING's stylesheet; the fan-out queues both classes. + writeFileSync(sibCssPath, '.PS_SPREADARR_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(sibCssPath), + [{ id: normalizePath(sibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${spreadArrPath}@SpreadArrayComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SPREADARR_OWN_MARKER') + expect(body).not.toContain('PS_SPREADARR_SIB_MARKER') + }) + it('serves a styleUrls entry written with a JavaScript escape', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const escCssPath = join(appDir, 'ps-esc.component.css') + const escSibCssPath = join(appDir, 'ps-esc-sib.component.css') + const escPath = join(appDir, 'ps-esc.component.ts') + writeFileSync(escCssPath, '.PS_ESC_OWN_MARKER { color: red; }') + writeFileSync(escSibCssPath, '.PS_ESC_SIB_MARKER { color: blue; }') + + // `\u002e` is `.` — the cooked value is `./ps-esc.component.css`, which + // is what the Rust extractor sees. A raw read yields a path that does + // not exist, and the per-style catch would swallow the failure. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-esc', + template: 'esc
', + styleUrls: ['\\u002e/ps-esc\\u002ecomponent.css'], + }) + export class EscComponent {} + @Component({ + selector: 'app-ps-esc-sib', + template: 'sib
', + styleUrls: ['./ps-esc-sib.component.css'], + }) + export class EscSiblingComponent {} + ` + writeFileSync(escPath, source) + await transformSource(plugin, source, escPath) + + writeFileSync(escSibCssPath, '.PS_ESC_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(escSibCssPath), + [{ id: normalizePath(escSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware(getMiddleware(mockServer), `${escPath}@EscComponent`) + expect(body).not.toBe('') + expect(body).toContain('PS_ESC_OWN_MARKER') + expect(body).not.toContain('PS_ESC_SIB_MARKER') + }) + + it('ignores a commented-out decorator when picking the styles of a class', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const realCssPath = join(appDir, 'ps-cmt-real.component.css') + const oldCssPath = join(appDir, 'ps-cmt-old.component.css') + const cmtSibCssPath = join(appDir, 'ps-cmt-sib.component.css') + const cmtPath = join(appDir, 'ps-cmt.component.ts') + writeFileSync(realCssPath, '.PS_CMT_REAL_MARKER { color: red; }') + writeFileSync(oldCssPath, '.PS_CMT_OLD_MARKER { color: blue; }') + writeFileSync(cmtSibCssPath, '.PS_CMT_SIB_MARKER { color: teal; }') + + // A leftover commented decorator sits between the real one and the class. + // Enumeration must not pair the class with the commented occurrence. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps-cmt', + template: 'cmt
', + styleUrls: ['./ps-cmt-real.component.css'], + }) + // @Component({ styleUrl: './ps-cmt-old.component.css' }) + export class CommentedComponent {} + @Component({ + selector: 'app-ps-cmt-sib', + template: 'sib
', + styleUrls: ['./ps-cmt-sib.component.css'], + }) + export class CommentedSiblingComponent {} + ` + writeFileSync(cmtPath, source) + await transformSource(plugin, source, cmtPath) + + writeFileSync(cmtSibCssPath, '.PS_CMT_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(cmtSibCssPath), + [{ id: normalizePath(cmtSibCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${cmtPath}@CommentedComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_CMT_REAL_MARKER') + expect(body).not.toContain('PS_CMT_OLD_MARKER') + expect(body).not.toContain('PS_CMT_SIB_MARKER') + }) + it('serves the own stylesheet of a class using a shorthand `styleUrl`', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const ownCssPath = join(appDir, 'ps-shorthand-own.component.css') + const sibCssPath = join(appDir, 'ps-shorthand-sib.component.css') + const shorthandPath = join(appDir, 'ps-shorthand.component.ts') + writeFileSync(ownCssPath, '.PS_SHORTHAND_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS_SHORTHAND_SIB_MARKER { color: red; }') + + // The shorthand key is a style field this scan cannot resolve, but the + // Rust extractor folds the constant behind it. Reading it as "declares + // no styles" would strip the component's CSS. + const source = ` + import { Component } from '@angular/core'; + const styleUrl = './ps-shorthand-own.component.css'; + @Component({ + selector: 'app-ps-shorthand', + template: 'shorthand
', + styleUrl, + }) + export class ShorthandComponent {} + @Component({ + selector: 'app-ps-shorthand-sib', + template: 'sib
', + styleUrls: ['./ps-shorthand-sib.component.css'], + }) + export class ShorthandSiblingComponent {} + ` + writeFileSync(shorthandPath, source) + await transformSource(plugin, source, shorthandPath) + + writeFileSync(ownCssPath, '.PS_SHORTHAND_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${shorthandPath}@ShorthandComponent`) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${shorthandPath}@ShorthandComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS_SHORTHAND_OWN_MARKER') + // The sibling's stylesheet rides along, because this is the file-level + // fallback: it is the union for the file. That is the known limitation + // tracked in #456, asserted the same way as the singular-constant case + // above. The defect fixed here is the own stylesheet going MISSING. + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 62a594337..3ae01fc69 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -39,10 +39,12 @@ import { emptyDelimitedRange, locateComponentDecorators, locateStylesFieldFor, + locateStyleFieldsFor, locateStylesInArgs, locateTemplateInArgs, locateTemplateStringFor, locateTemplateUrlFor, + readStringLiterals, } from './utils/decorator-fields.js' import { injectDtsDeclarations } from './utils/dts.js' @@ -686,10 +688,18 @@ export function angular(options: PluginOptions = {}): Plugin[] { // disk and run through Vite's preprocessCSS (so SCSS/LESS // resolve correctly); inline styles are extracted from the // .ts source as plain CSS strings. - let styles: string[] | null = null - if (styleUrls.length > 0) { + // + // Resolve per CLASS, like the template above: the file-level + // `styleUrls` is the union of every component in the file, so + // in a multi-component file it served a class its siblings' + // stylesheets — including a class declaring no styles at all — + // and, being non-empty, it also shadowed the inline `styles:` + // of a class that has no external ones. Fall back to the + // file-level list only when the class's own styles cannot be + // read (preserves the old behavior there). + const readStyles = async (urls: string[]): Promise