diff --git a/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts b/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts index 15a5f6a0f..ad66d7c4f 100644 --- a/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts +++ b/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts @@ -6,9 +6,9 @@ import { test, expect } from '../fixtures/test-fixture.js' * - receive its own per-component HMR update when its template or styles * change (NO full reload), without disturbing the sibling component. * - * Guards the per-component cache + dispatch wiring (componentsByFile, - * filePath@ClassName-keyed inlineTemplateCache / inlineStylesCache, - * pendingHmrUpdates per componentId). + * Guards the per-component dispatch wiring (componentsByFile, one + * `filePath@ClassName` entry in pendingHmrUpdates per componentId, and the + * per-class metadata the `@ng/component` endpoint resolves for each). */ test.describe('Multi-component file HMR', () => { test.beforeEach(async ({ page }) => { diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index bf0d4976b..f36a7c73a 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -271,6 +271,26 @@ export declare function extractAngularComponentByAst( className: string, ): ComponentExtractionResult +/** + * Extract component metadata from all `@Component` decorated classes in a TypeScript file. + * + * This parses the file and extracts metadata from `@Component` decorators + * on class declarations. + * + * # Arguments + * + * * `source` - The TypeScript source code + * * `file_path` - The file path (for error messages and source type detection) + * + * # Returns + * + * A vector of `ExtractedComponentMetadata` for each component found. + */ +export declare function extractComponentMetadataSync( + source: string, + filePath: string, +): Array + /** * Extract templateUrl and styleUrls from all @Component decorators in a file (async). * diff --git a/napi/angular-compiler/index.js b/napi/angular-compiler/index.js index 6db164fd4..de64c77a2 100644 --- a/napi/angular-compiler/index.js +++ b/napi/angular-compiler/index.js @@ -902,6 +902,7 @@ const { encapsulateStyle, encodeComponentId, extractAngularComponentByAst, + extractComponentMetadataSync, extractComponentUrls, generateHmrModule, generateStyleModule, @@ -922,6 +923,7 @@ export { decodeComponentId } export { encapsulateStyle } export { encodeComponentId } export { extractAngularComponentByAst } +export { extractComponentMetadataSync } export { extractComponentUrls } export { generateHmrModule } export { generateStyleModule } diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 5d1f2d901..e4ef593ed 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1544,6 +1544,7 @@ pub struct ExtractedComponentMetadata { /// # Returns /// /// A vector of `ExtractedComponentMetadata` for each component found. +#[napi] pub fn extract_component_metadata_sync( source: String, file_path: String, diff --git a/napi/angular-compiler/test/decorator-fields.test.ts b/napi/angular-compiler/test/decorator-fields.test.ts index 816d14c9a..d17baef83 100644 --- a/napi/angular-compiler/test/decorator-fields.test.ts +++ b/napi/angular-compiler/test/decorator-fields.test.ts @@ -3,15 +3,29 @@ import { describe, expect, it } from 'vitest' import { emptyDelimitedRange, locateComponentDecorators, - locateStyleFieldsFor, - locateStyleUrlFor, - locateStyleUrlsFor, - locateStylesFieldFor, - locateTemplateStringFor, - locateTemplateUrlFor, - readStringLiterals, + locateStylesInArgs, + locateTemplateInArgs, } from '../vite-plugin/utils/decorator-fields.js' +// The className-keyed wrappers that used to live in decorator-fields.ts went +// away with the text-scan HMR path — per-class resources now come from the +// Rust extractor. What survives is the metadata *strip* that decides HMR +// versus full reload, which reaches the same locators through +// `locateComponentDecorators`. These two mirror what the removed wrappers +// did, so the tests below still pin behaviour that ships. +const stylesFieldFor = (code: string, className: string): [number, number] | null => { + const found = locateComponentDecorators(code).find((d) => d.className === className) + return found ? locateStylesInArgs(code, found.argsRange) : null +} + +const templateFieldFor = (code: string, className: string): [number, number] | null => { + const found = locateComponentDecorators(code).find((d) => d.className === className) + return found ? locateTemplateInArgs(code, found.argsRange) : null +} + +/** The source text a located range covers, outer delimiters included. */ +const textOf = (code: string, range: [number, number]): string => code.slice(range[0], range[1] + 1) + describe('decorator-fields utils', () => { describe('emptyDelimitedRange', () => { it('empties the body of a styles array but keeps the brackets', () => { @@ -63,73 +77,61 @@ describe('decorator-fields utils', () => { // 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' })`, + `@Component({ selector: 'x', styles: ['.real{}'] })`, + `// @Component({ styles: ['.old{}'] })`, `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', - ]) + expect(textOf(src, stylesFieldFor(src, 'FooComponent')!)).toBe(`['.real{}']`) }) 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' }) */`, + `@Component({ selector: 'x', styles: ['.real{}'] })`, + `/* @Component({ styles: ['.old{}'] }) */`, `export class FooComponent {}`, ].join('\n') - expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ - './real.css', - ]) + expect(textOf(src, stylesFieldFor(src, 'FooComponent')!)).toBe(`['.real{}']`) }) it('ignores a commented-out decorator that precedes the real one', () => { const src = [ - `// @Component({ styleUrl: './old.css' })`, - `@Component({ selector: 'x', styleUrls: ['./real.css'] })`, + `// @Component({ styles: ['.old{}'] })`, + `@Component({ selector: 'x', styles: ['.real{}'] })`, `export class FooComponent {}`, ].join('\n') const out = locateComponentDecorators(src) expect(out).toHaveLength(1) - expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ - './real.css', - ]) + expect(textOf(src, stylesFieldFor(src, 'FooComponent')!)).toBe(`['.real{}']`) }) 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'] })`, + `const doc = 'see @Component({ styles: [".str{}"] }) for details';`, + `@Component({ selector: 'x', styles: ['.real{}'] })`, `export class FooComponent {}`, ].join('\n') const out = locateComponentDecorators(src) expect(out).toHaveLength(1) - expect(readStringLiterals(src, locateStyleUrlsFor(src, 'FooComponent')!).literals).toEqual([ - './real.css', - ]) + expect(textOf(src, stylesFieldFor(src, 'FooComponent')!)).toBe(`['.real{}']`) }) 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' })`, + `@Component({ selector: 'a', styles: ['.a{}'] })`, + `// @Component({ styles: ['.fake{}'] })`, `export class AComponent {}`, - `@Component({ selector: 'b', styleUrls: ['./b.css'] })`, + `@Component({ selector: 'b', styles: ['.b{}'] })`, `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', - ]) + expect(textOf(src, stylesFieldFor(src, 'AComponent')!)).toBe(`['.a{}']`) + expect(textOf(src, stylesFieldFor(src, 'BComponent')!)).toBe(`['.b{}']`) }) it('returns a single entry for a single-component file', () => { @@ -233,7 +235,7 @@ describe('decorator-fields utils', () => { }) }) - describe('locateStylesFieldFor', () => { + describe('locateStylesInArgs', () => { const multi = ` @Component({ selector: 'a', styles: ['.first {}'] }) export class FirstComponent {} @@ -242,22 +244,20 @@ describe('decorator-fields utils', () => { ` it('returns null when className matches no decorator', () => { - expect(locateStylesFieldFor(multi, 'Nope')).toBeNull() + expect(stylesFieldFor(multi, 'Nope')).toBeNull() }) it('returns null when the named component has no styles field', () => { const src = `@Component({ template: '

' })\nexport class Foo {}` - expect(locateStylesFieldFor(src, 'Foo')).toBeNull() + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) it('returns the FirstComponent styles range when asked for FirstComponent', () => { - const range = locateStylesFieldFor(multi, 'FirstComponent')! - expect(multi.slice(range[0], range[1] + 1)).toBe(`['.first {}']`) + expect(textOf(multi, stylesFieldFor(multi, 'FirstComponent')!)).toBe(`['.first {}']`) }) it('returns the SecondComponent styles range when asked for SecondComponent', () => { - const range = locateStylesFieldFor(multi, 'SecondComponent')! - expect(multi.slice(range[0], range[1] + 1)).toBe(`['.second {}']`) + expect(textOf(multi, stylesFieldFor(multi, 'SecondComponent')!)).toBe(`['.second {}']`) }) it('supports the bare-string styles form per component', () => { @@ -267,8 +267,7 @@ describe('decorator-fields utils', () => { @Component({ styles: '.second {}' }) export class SecondComponent {} ` - const range = locateStylesFieldFor(src, 'SecondComponent')! - expect(src.slice(range[0], range[1] + 1)).toBe(`'.second {}'`) + expect(textOf(src, stylesFieldFor(src, 'SecondComponent')!)).toBe(`'.second {}'`) }) // The next four guard against false-matches: a `styles:` key occurring @@ -276,13 +275,12 @@ describe('decorator-fields utils', () => { it('ignores `styles:` text inside a template literal that precedes the real styles', () => { const src = "@Component({ template: `

const cfg = { styles: ['fake'] }
`, styles: ['real'] })\nexport class Foo {}" - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('returns null when the only `styles:` text in the args is inside a template literal', () => { const src = "@Component({ template: `
{ styles: ['fake'] }
` })\nexport class Bar {}" - expect(locateStylesFieldFor(src, 'Bar')).toBeNull() + expect(stylesFieldFor(src, 'Bar')).toBeNull() }) it("ignores `styles:` inside a `${...}` interpolation's nested object literal", () => { @@ -290,20 +288,28 @@ describe('decorator-fields utils', () => { // be treated as a top-level @Component metadata property. const src = "@Component({ template: `${doThing({ styles: ['fake'] })}`, styles: ['real'] })\nexport class Baz {}" - const range = locateStylesFieldFor(src, 'Baz')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Baz')!)).toBe(`['real']`) }) it('ignores `styles:` inside a nested non-metadata object literal', () => { // `metadata: { styles: ['nested'] }` is not the component's `styles` // field; only top-level properties of the @Component argument count. const src = `@Component({ host: { '[styles]': 'expr', styles: 'irrelevant' }, styles: ['real'] })\nexport class Qux {}` - const range = locateStylesFieldFor(src, 'Qux')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Qux')!)).toBe(`['real']`) + }) + + it('does not match a `styleUrls:` field as the inline `styles:` field', () => { + const src = `@Component({ styleUrls: ['./a.css'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() + }) + + it('does not match the singular `styleUrl:` field as the inline `styles:` field', () => { + const src = `@Component({ styleUrl: './a.css' })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) }) - describe('locateTemplateStringFor', () => { + describe('locateTemplateInArgs', () => { const multi = ` @Component({ selector: 'a', template: '' }) export class FirstComponent {} @@ -312,22 +318,20 @@ describe('decorator-fields utils', () => { ` it('returns null when className matches no decorator', () => { - expect(locateTemplateStringFor(multi, 'Nope')).toBeNull() + expect(templateFieldFor(multi, 'Nope')).toBeNull() }) it('returns null when the named component has no template field', () => { const src = `@Component({ styles: [] })\nexport class Foo {}` - expect(locateTemplateStringFor(src, 'Foo')).toBeNull() + expect(templateFieldFor(src, 'Foo')).toBeNull() }) it('returns the FirstComponent template range when asked for FirstComponent', () => { - const range = locateTemplateStringFor(multi, 'FirstComponent')! - expect(multi.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(multi, templateFieldFor(multi, 'FirstComponent')!)).toBe(`''`) }) it('returns the SecondComponent template range when asked for SecondComponent', () => { - const range = locateTemplateStringFor(multi, 'SecondComponent')! - expect(multi.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(multi, templateFieldFor(multi, 'SecondComponent')!)).toBe(`''`) }) it("ignores `template:` text appearing inside another field's string literal", () => { @@ -335,848 +339,281 @@ describe('decorator-fields utils', () => { // the real `template:` field comes after. The naive regex would match // the inner one first. const src = `@Component({ styles: ['/* template: "fake" */'], template: '' })\nexport class Foo {}` - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) it('does not match a `templateUrl:` field as `template:`', () => { const src = `@Component({ templateUrl: './foo.html' })\nexport class Foo {}` - expect(locateTemplateStringFor(src, 'Foo')).toBeNull() - }) - }) - - describe('locateTemplateUrlFor', () => { - const multi = ` - @Component({ selector: 'a', templateUrl: './first.html' }) - export class FirstComponent {} - @Component({ selector: 'b', templateUrl: './second.html' }) - export class SecondComponent {} - ` - - it('returns null when className matches no decorator', () => { - expect(locateTemplateUrlFor(multi, 'Nope')).toBeNull() + expect(templateFieldFor(src, 'Foo')).toBeNull() }) - it('returns null when the named component has no templateUrl field', () => { - const src = `@Component({ template: '

' })\nexport class Foo {}` - expect(locateTemplateUrlFor(src, 'Foo')).toBeNull() - }) - - it('returns each component its own templateUrl range in a multi-component file', () => { - const first = locateTemplateUrlFor(multi, 'FirstComponent')! - const second = locateTemplateUrlFor(multi, 'SecondComponent')! - expect(multi.slice(first[0], first[1] + 1)).toBe(`'./first.html'`) - expect(multi.slice(second[0], second[1] + 1)).toBe(`'./second.html'`) - }) - - it('does not match an inline `template:` field as `templateUrl:`', () => { - const src = `@Component({ template: '

templateUrl: fake

' })\nexport class Foo {}` - expect(locateTemplateUrlFor(src, 'Foo')).toBeNull() - }) - - it('finds templateUrl when the decorator also has an inline template field', () => { - const src = `@Component({ template: '

', templateUrl: './real.html' })\nexport class Foo {}` - const range = locateTemplateUrlFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`'./real.html'`) + it('finds the inline template when the decorator also has a templateUrl field', () => { + const src = `@Component({ templateUrl: './real.html', template: '

' })\nexport class Foo {}` + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`'

'`) }) }) - 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' }, - }) + // ----------------------------------------------------------------- + // Which property keys count as the field, and which must not. The + // strip has to empty exactly the component's own `template:`/`styles:` + // — emptying anything else, or missing the real one, changes the + // stripped bytes and flips the HMR / full-reload decision. + // ----------------------------------------------------------------- + describe('which keys count as the field', () => { + it('does not locate a field whose value has no literal shape', () => { + const src = `@Component({ styles: STYLES })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - // 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']) + const src = `@Component({ 'styles': ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) 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']) + const src = `@Component({ "styles": ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - 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('reads a quoted `template` key', () => { + const src = `@Component({ 'template': '

' })\nexport class Foo {}` + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`'

'`) }) 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' }, - }) + expect(stylesFieldFor(urls, 'Foo')).toBeNull() + const tplUrl = `@Component({ 'templateUrl': './a.html' })\nexport class Foo {}` + expect(templateFieldFor(tplUrl, 'Foo')).toBeNull() }) - 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' }, - }) + it('reads the real field past a computed key', () => { + // A computed key hides its name from this scan, but it is not the + // field we are after, and the visible field is still exactly what it + // says it is. + const src = `@Component({ [K]: 1, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - // 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('does not locate a computed key as a field', () => { + const src = `@Component({ [K]: ['.x{}'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - 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('reads the real field past a spread', () => { + const src = `@Component({ ...BASE, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - it('leaves a shorthand inline `styles` absent, which the compiler also drops', () => { + it('does not locate a shorthand style field, which has no value to empty', () => { const src = `@Component({ template: '

', styles })\nexport class Foo {}` - expect(locateStyleFieldsFor(src, 'Foo')!.inline).toEqual({ kind: 'absent' }) + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) 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']) + const src = `@Component({ selector, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - // 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('reads the real field past a field-named identifier used as a value', () => { + const src = `@Component({ selector: styles, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) 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']) + const src = `@Component({ foo() { return 1 }, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - // 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' }) + // A method or accessor named like the field is not the field: there is + // no value literal to empty, and emptying its body would be wrong. + it('does not locate a method named like the field', () => { + const src = `@Component({ styles() { return ['.x{}'] } })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - 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('does not locate a getter named like the field', () => { + const src = `@Component({ get template() { return '

' } })\nexport class Foo {}` + expect(templateFieldFor(src, 'Foo')).toBeNull() }) - 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('reads the real field past a setter named like it', () => { + const src = `@Component({ set styles(v) {}, styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - 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('does not read a field nested in a deeper object', () => { + const src = `@Component({ data: { styles: ['.deep{}'] } })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) 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']) - }) + const src = `@Component({ styles: ['.x{}'], })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) }) - // ----------------------------------------------------------------- - // 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, - }) - }) + // A value that merely STARTS with a literal does not denote it. Emptying + // that leading piece would leave the rest of the expression in the + // stripped source, so an edit to the expression would read as a + // non-metadata change — or worse, an edit elsewhere would not. + describe('a literal that is only the start of a larger expression', () => { + const styles = (field: string) => + stylesFieldFor(`@Component({ selector: 'a', ${field} })\nexport class Foo {}`, 'Foo') + const template = (field: string) => + templateFieldFor(`@Component({ selector: 'a', ${field} })\nexport class Foo {}`, 'Foo') - it('reads a spread-only array as declaring nothing', () => { - expect(readOf(`[...SHARED]`)).toEqual({ literals: [], complete: true }) + it('rejects an inline `styles` string concatenated with an identifier', () => { + expect(styles(`styles: '.a{}' + EXTRA`)).toBeNull() }) - it('reads an array of two spreads as declaring nothing', () => { - expect(readOf(`[...S1, ...S2]`)).toEqual({ literals: [], complete: true }) + it('rejects a `template` concatenated with another literal', () => { + expect(template(`template: '

' + ''`)).toBeNull() }) - 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('rejects a `template` concatenated with an identifier', () => { + expect(template(`template: '

' + SUFFIX`)).toBeNull() }) - 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('rejects a `styles` array concatenated with an identifier', () => { + expect(styles(`styles: ['.a{}'] + EXTRA`)).toBeNull() }) - 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('rejects a method call on an inline `styles` string literal', () => { + expect(styles(`styles: '.a{}'.replace('a', 'b')`)).toBeNull() }) - 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('rejects a method call on a `styles` array literal', () => { + expect(styles(`styles: ['.a{}'].concat(MORE)`)).toBeNull() }) - 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('rejects a method call on a `template` literal', () => { + expect(template(`template: '

'.replace('a', 'b')`)).toBeNull() }) - 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('rejects a TypeScript `as` assertion after an inline `styles` string', () => { + expect(styles(`styles: '.a{}' as string`)).toBeNull() }) - it('ignores a block comment inside a spread', () => { - expect(readOf(`[.../* , './leak.css' */ SHARED, './own.css']`)).toEqual({ - literals: ['./own.css'], - complete: true, - }) + it('rejects a TypeScript `as const` assertion after a `styles` array', () => { + expect(styles(`styles: ['.a{}'] as const`)).toBeNull() }) - 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('rejects a TypeScript `as` assertion after a `template`', () => { + expect(template(`template: '

' as string`)).toBeNull() }) - 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('rejects a non-null assertion after an inline `styles` string', () => { + expect(styles(`styles: '.a{}'!`)).toBeNull() }) - 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('rejects a `satisfies` clause after an inline `styles` string', () => { + expect(styles(`styles: '.a{}' satisfies string`)).toBeNull() }) - it('drops a spread in an inline `styles` array', () => { - expect(readOf(`[...SHARED_INLINE, ':host{}']`)).toEqual({ - literals: [':host{}'], - complete: true, - }) + it('rejects a trailing expression hidden behind a comment', () => { + expect(styles(`styles: '.a{}' /* why */ + EXTRA`)).toBeNull() }) - 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, - }) + // 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([ + [`styles: ['.a{}']`, `the object's closing brace`, `['.a{}']`], + [`styles: ['.a{}'],`, 'a trailing comma', `['.a{}']`], + [`styles: '.a{}' /* why */`, 'a block comment then the brace', `'.a{}'`], + [`styles: '.a{}' // why\n`, 'a line comment then the brace', `'.a{}'`], + ])('still reads %j, ended by %s', (field, _why, expected) => { + const src = `@Component({ selector: 'a', ${field} })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(expected) }) - 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.each([ + [`template: '

'`, `the object's closing brace`], + [`template: '

',`, 'a trailing comma'], + ])('still reads %j, ended by %s', (field) => { + const src = `@Component({ selector: 'a', ${field} })\nexport class Foo {}` + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`'

'`) }) + }) - 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, - }) + // Escaped identifier keys. `styles` IS `styles` to the TypeScript + // parser, so the strip has to empty it like any other spelling of the + // field. Decoding keeps the match exact where refusing would silently + // leave a real `styles:` field in the stripped source. + describe('escaped keys', () => { + it('decodes a \\uHHHH escape in a bare key', () => { + const src = `@Component({ style\\u0073: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - it('does not treat a leading decimal point as a spread', () => { - expect(readOf(`[.5, './a.css']`).complete).toBe(false) + it('decodes a \\u{…} escape in a bare key', () => { + const src = `@Component({ style\\u{73}: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - it('does not treat two dots as a spread', () => { - expect(readOf(`[..X, './a.css']`).complete).toBe(false) + it('decodes an escape at the first character of a bare key', () => { + const src = `@Component({ \\u0073tyles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - it('does not treat a member access as a spread', () => { - expect(readOf(`[STYLES.a, './a.css']`).complete).toBe(false) + it('decodes an escaped `template` key', () => { + const src = `@Component({ templat\\u0065: '

' })\nexport class Foo {}` + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`'

'`) }) - it('does not treat an optional chain as a spread', () => { - expect(readOf(`[STYLES?.a, './a.css']`).complete).toBe(false) + 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\\u0073': ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) - 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('does not locate an escaped shorthand, which has no value to empty', () => { + const src = `@Component({ template: '

', style\\u0073 })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - it('reads across a leading elision, which the compiler also drops', () => { - expect(readOf(`[, './a.css']`)).toEqual({ literals: ['./a.css'], complete: true }) + it('does not match a decoded key that names something else', () => { + const src = `@Component({ style\\u0073Extra: ['.x{}'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - it('reads across an elision between two entries', () => { - expect(readOf(`['./a.css', , './b.css']`)).toEqual({ - literals: ['./a.css', './b.css'], - complete: true, - }) + it('keeps the cross-match guards for decoded keys', () => { + const urls = `@Component({ style\\u0055rls: ['./a.css'] })\nexport class Foo {}` + expect(stylesFieldFor(urls, 'Foo')).toBeNull() + const tplUrl = `@Component({ templat\\u0065Url: './a.html' })\nexport class Foo {}` + expect(templateFieldFor(tplUrl, 'Foo')).toBeNull() }) - 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('does not match a lookalike built from a non-ASCII letter', () => { + // Cyrillic е in place of `e` — a different identifier entirely. + const src = `@Component({ stylеs: ['.x{}'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - it('reports a bare interpolated template literal as incomplete', () => { - expect(readOf('`${DIR}/a.css`').complete).toBe(false) + it('does not match a key whose \\u escape is malformed', () => { + const src = `@Component({ style\\u00ZZs: ['.x{}'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - 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('does not match a key carrying a \\xHH escape — illegal in an identifier', () => { + const src = `@Component({ style\\x73: ['.x{}'] })\nexport class Foo {}` + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) - 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) - } + it('does not let an escaped unrelated key degrade a readable field', () => { + const src = `@Component({ sel\\u0065ctor: 'a', styles: ['.x{}'] })\nexport class Foo {}` + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['.x{}']`) }) }) + // ----------------------------------------------------------------- + // 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). + // ----------------------------------------------------------------- describe('comment handling in @Component args', () => { it('does not get stuck on an apostrophe inside a line comment', () => { const src = `@Component({ @@ -1184,8 +621,7 @@ describe('decorator-fields utils', () => { styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('does not get stuck on apostrophes inside a block comment', () => { @@ -1194,8 +630,7 @@ class Foo {}` styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('ignores `styles:` inside a line comment', () => { @@ -1204,8 +639,7 @@ class Foo {}` styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('ignores `styles:` inside a block comment', () => { @@ -1214,8 +648,7 @@ class Foo {}` styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('ignores `template:` inside a block comment', () => { @@ -1224,8 +657,7 @@ class Foo {}` template: '' }) class Foo {}` - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) it('returns null when the only `styles:` is inside a comment', () => { @@ -1234,7 +666,7 @@ class Foo {}` selector: 'app-foo' }) class Foo {}` - expect(locateStylesFieldFor(src, 'Foo')).toBeNull() + expect(stylesFieldFor(src, 'Foo')).toBeNull() }) it('handles a block comment spanning multiple lines', () => { @@ -1246,8 +678,7 @@ class Foo {}` styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('handles a comment between @Component(...) and the class declaration', () => { @@ -1263,141 +694,105 @@ export class Foo {}` // `'http://x'` is a URL in a value, not a comment. const src = `@Component({ template: 'http://x', styles: ['real'] }) class Foo {}` - const tRange = locateTemplateStringFor(src, 'Foo')! - const sRange = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(tRange[0], tRange[1] + 1)).toBe(`'http://x'`) - expect(src.slice(sRange[0], sRange[1] + 1)).toBe(`['real']`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`'http://x'`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('does NOT treat `/*` inside a string as a block comment', () => { const src = `@Component({ template: '/* not a comment */', styles: ['real'] }) 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 - } + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) + }) + + // A comment may sit anywhere inside a field declaration, and the strip + // has to read straight past it — both to find the field at all, and to + // put the range's closing delimiter in the right place. + describe('comment placement within a field declaration', () => { const decorator = (field: string) => - `@Component({ template: '

', ${field} })\nexport class Foo {}` + `@Component({ selector: 'a', ${field} })\nexport class Foo {}` + const stylesText = (field: string) => { + const src = decorator(field) + return textOf(src, stylesFieldFor(src, 'Foo')!) + } + const templateText = (field: string) => { + const src = decorator(field) + return textOf(src, templateFieldFor(src, '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']) + expect(stylesText(`styles /* why */: ['.x{}']`)).toBe(`['.x{}']`) }) 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']) + expect(stylesText(`styles // why\n: ['.x{}']`)).toBe(`['.x{}']`) }) it('reads a field with a comment between the colon and the value', () => { - expect(urlsOf(decorator(`styleUrls: /* why */ ['./x.css']`))).toEqual(['./x.css']) + expect(stylesText(`styles: /* why */ ['.x{}']`)).toBe(`['.x{}']`) }) it('reads a field with a comment before the key', () => { - expect(urlsOf(decorator(`/* why */ styleUrls: ['./x.css']`))).toEqual(['./x.css']) + expect(stylesText(`/* why */ styles: ['.x{}']`)).toBe(`['.x{}']`) }) it('reads a field with two comments between the key and the colon', () => { - expect(urlsOf(decorator(`styleUrls /* a */ /* b */: ['./x.css']`))).toEqual(['./x.css']) + expect(stylesText(`styles /* a */ /* b */: ['.x{}']`)).toBe(`['.x{}']`) }) 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']) + expect(stylesText(`'styles' /* why */: ['.x{}']`)).toBe(`['.x{}']`) }) - it('reads the singular `styleUrl` with a comment after the colon', () => { - expect(urlsOf(decorator(`styleUrl: /* why */ './x.css'`))).toEqual(['./x.css']) + it('reads `template` with a comment before the colon', () => { + expect(templateText(`template /* why */: '

'`)).toBe(`'

'`) }) - it('reads an array whose first literal follows a comment', () => { - expect(urlsOf(decorator(`styleUrls: [/* why */ './x.css']`))).toEqual(['./x.css']) + it('reads `template` with a comment after the colon', () => { + expect(templateText(`template: /* why */ '

'`)).toBe(`'

'`) }) - it('reads an array whose first literal follows a line comment', () => { - expect(urlsOf(decorator(`styleUrls: [// why\n './x.css']`))).toEqual(['./x.css']) + // The range has to span the whole array, comments included — the strip + // empties everything between the brackets. + it('spans an array whose first entry follows a block comment', () => { + expect(stylesText(`styles: [/* why */ '.x{}']`)).toBe(`[/* why */ '.x{}']`) }) - 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('spans an array whose first entry follows a line comment', () => { + expect(stylesText(`styles: [// why\n '.x{}']`)).toBe(`[// why\n '.x{}']`) }) - 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('spans an array with a comment before the separating comma', () => { + expect(stylesText(`styles: ['.x{}' /* why */, '.y{}']`)).toBe(`['.x{}' /* why */, '.y{}']`) }) - it('reads an array with a comment after the last literal', () => { - expect(urlsOf(decorator(`styleUrls: ['./x.css' /* why */]`))).toEqual(['./x.css']) + it('spans an array with a comment after the last entry', () => { + expect(stylesText(`styles: ['.x{}' /* why */]`)).toBe(`['.x{}' /* why */]`) }) - 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{}']) + it('does not let a `]` inside a comment close the array early', () => { + expect(stylesText(`styles: ['.x{}' /* ] */]`)).toBe(`['.x{}' /* ] */]`) }) // 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([]) + // array, and emptying it is still the right answer. + it('spans an array holding only a comment, which strips to `[]`', () => { + const src = decorator(`styles: [/* why */]`) + const range = stylesFieldFor(src, 'Foo')! + expect(textOf(src, range)).toBe(`[/* why */]`) + expect(emptyDelimitedRange(src, range)).toContain(`styles: []`) }) // 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']) + expect(stylesText(`styles /* a: b, c ] [ */: ['.x{}']`)).toBe(`['.x{}']`) }) - it('ignores a decoy style field inside the comment', () => { - expect(urlsOf(decorator(`styleUrls /* styleUrl: './fake.css' */: ['./x.css']`))).toEqual([ - './x.css', - ]) + it('ignores a decoy field inside the comment', () => { + expect(stylesText(`styles /* styles: './fake.css' */: ['.x{}']`)).toBe(`['.x{}']`) }) 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' }) + expect(stylesText(`styles /* don't */: ['.x{}']`)).toBe(`['.x{}']`) }) }) }) @@ -1416,58 +811,50 @@ export class Foo {}` */ @Component({ template: '' }) export class Foo {}` - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) it('ignores `@Component(...)` text inside a string literal preceding the real decorator', () => { const src = `const docs = "use @Component({ template: 'fake' }) to declare" @Component({ template: '' }) class Foo {}` - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) it('ignores `@Component(...)` text inside a backtick template preceding the real decorator', () => { const src = "`Use @Component({ template: 'fake' })`\n@Component({ template: '' })\nclass Foo {}" - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) it('does not get confused by a `template:` literal that mentions the word "styles:"', () => { const src = `@Component({ template: 'styles: ["fake"]', styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('handles unbalanced braces or brackets inside template string content', () => { const src = `@Component({ template: 'has { and ] literally', styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('treats CRLF line endings the same as LF', () => { const src = `@Component({\r\n // a comment with an apostrophe: I'm here\r\n styles: ['real']\r\n})\r\nclass Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('handles `...spread` followed by a real `styles:` field', () => { const src = `const base = { selector: 'app' } @Component({ ...base, styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('handles a selector value that contains parens', () => { const src = `@Component({ selector: 'foo(bar)', styles: ['real'] }) class Foo {}` - const range = locateStylesFieldFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + expect(textOf(src, stylesFieldFor(src, 'Foo')!)).toBe(`['real']`) }) it('ignores a class member method named `Component`', () => { @@ -1488,16 +875,13 @@ const helper = () => '@Component({...})' class Second {}` const out = locateComponentDecorators(src) expect(out.map((d) => d.className)).toEqual(['First', 'Second']) - const fRange = locateTemplateStringFor(src, 'First')! - const sRange = locateTemplateStringFor(src, 'Second')! - expect(src.slice(fRange[0], fRange[1] + 1)).toBe(`''`) - expect(src.slice(sRange[0], sRange[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'First')!)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Second')!)).toBe(`''`) }) it('handles literal `$` followed by `${...}` interpolation in a template literal', () => { const src = '@Component({ template: `cost $5 or $${price}` })\nclass Foo {}' - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe('`cost $5 or $${price}`') + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe('`cost $5 or $${price}`') }) it('coexists with other class-level decorators like @SignalComponent', () => { @@ -1507,8 +891,7 @@ class Foo {}` // Only @Component is recognized; @SignalComponent is ignored entirely. const out = locateComponentDecorators(src) expect(out).toHaveLength(1) - const range = locateTemplateStringFor(src, 'Foo')! - expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + expect(textOf(src, templateFieldFor(src, 'Foo')!)).toBe(`''`) }) }) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index d8e85d444..cc00e8aff 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -1350,6 +1350,97 @@ describe('@ng/component endpoint resolves the template per class', () => { expect(body).toContain('PC_INLINE_MARKER') expect(body).not.toContain('PC_EXT_MARKER') }) + + // ---------------------------------------------------------------- + // Issue #456 — a templateUrl the compiler folds but a text scan cannot + // ---------------------------------------------------------------- + // The endpoint used to resolve `templateUrl` by scanning decorator text for + // a string literal. A constant or an interpolated template literal holds + // none, so the class fell through to the FILE's first external template — + // which in both fixtures below belongs to the SIBLING declared before it. + // The compiler folds these forms, so the per-class metadata does too. + + it('serves the template of a class whose `templateUrl` is a same-file constant', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const sibHtmlPath = join(appDir, 'pc-cf-sib.component.html') + const ownHtmlPath = join(appDir, 'pc-cf-own.component.html') + const constPath = join(appDir, 'pc-cf.component.ts') + writeFileSync(sibHtmlPath, '

PC_CF_SIB_MARKER

') + writeFileSync(ownHtmlPath, '

PC_CF_OWN_MARKER

') + + // The sibling is declared FIRST, so the file-level `templateUrls[0]` is + // its template — the exact thing the old fallback served this class. + const source = ` + import { Component } from '@angular/core'; + const PC_CF_TEMPLATE = './pc-cf-own.component.html'; + @Component({ selector: 'app-pc-cf-sib', templateUrl: './pc-cf-sib.component.html' }) + export class CfSiblingComponent {} + @Component({ selector: 'app-pc-cf-own', templateUrl: PC_CF_TEMPLATE }) + export class CfConstComponent {} + ` + writeFileSync(constPath, source) + await transformSource(plugin, source, constPath) + + writeFileSync(ownHtmlPath, '

PC_CF_OWN_MARKER edited

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(ownHtmlPath), + [{ id: normalizePath(ownHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${constPath}@CfConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PC_CF_OWN_MARKER') + expect(body).not.toContain('PC_CF_SIB_MARKER') + }) + + it('serves the template of a class whose `templateUrl` is interpolated', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const sibHtmlPath = join(appDir, 'pc-ti-sib.component.html') + const ownHtmlPath = join(appDir, 'pc-ti-own.component.html') + const interpPath = join(appDir, 'pc-ti.component.ts') + writeFileSync(sibHtmlPath, '

PC_TI_SIB_MARKER

') + writeFileSync(ownHtmlPath, '

PC_TI_OWN_MARKER

') + + const source = ` + import { Component } from '@angular/core'; + const PC_TI_DIR = '.'; + @Component({ selector: 'app-pc-ti-sib', templateUrl: './pc-ti-sib.component.html' }) + export class TiSiblingComponent {} + @Component({ selector: 'app-pc-ti-own', templateUrl: \`\${PC_TI_DIR}/pc-ti-own.component.html\` }) + export class TiInterpComponent {} + ` + writeFileSync(interpPath, source) + await transformSource(plugin, source, interpPath) + + writeFileSync(ownHtmlPath, '

PC_TI_OWN_MARKER edited

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(ownHtmlPath), + [{ id: normalizePath(ownHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${interpPath}@TiInterpComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PC_TI_OWN_MARKER') + expect(body).not.toContain('PC_TI_SIB_MARKER') + }) }) describe('@ng/component endpoint resolves the styles per class', () => { @@ -1378,15 +1469,17 @@ describe('@ng/component endpoint resolves the styles per class', () => { return mockServer } + // Returns the transform result so a test can compare what the COMPILER + // resolved against what the endpoint serves. async function transformSource(plugin: Plugin, source: string, path: string) { if (!plugin.transform || typeof plugin.transform === 'function') { throw new Error('Expected plugin transform handler') } - await plugin.transform.handler.call( + return (await plugin.transform.handler.call( { error() {}, warn() {}, addWatchFile() {} } as any, source, path, - ) + )) as { code: string } | undefined } // The component-file branch queues `pendingHmrUpdates` under `ctx.file` @@ -1722,7 +1815,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) }) - it('falls back to the file-level styleUrls when a class uses a non-literal entry', async () => { + it('serves the folded stylesheet of a class whose `styleUrls` entry is a constant', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -1730,9 +1823,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // The URL comes from a const. The extractor folds it, so the class's own + // `styleUrls` already names the real path — no file-level guess needed. const source = ` import { Component } from '@angular/core'; const STYLE_URL = './ps-const.component.css'; @@ -1762,7 +1854,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).toContain('PS_CONST_MARKER') }) - it('falls back when the singular `styleUrl` is a same-file constant', async () => { + it('serves the own stylesheet of a class whose singular `styleUrl` is a constant', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -1772,9 +1864,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // The extractor folds the const (verified: it reports ./own.css), which is + // also what the component compiles with — so this class gets exactly that + // stylesheet, and its styled sibling's never rides along. const source = ` import { Component } from '@angular/core'; const STYLE_URL = './ps-singconst-own.component.css'; @@ -1951,7 +2043,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).toContain('styles: []') }) - it('falls back when a `styleUrls` array mixes a constant with a literal', async () => { + it('serves both entries of a `styleUrls` array mixing a constant with a literal', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -1961,9 +2053,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // One entry is a const. The extractor folds it, so the resolved list is + // both paths in order — the same list `resolve_styles` feeds the compiled + // component. Returning just the literal would silently drop a stylesheet. const source = ` import { Component } from '@angular/core'; const MIXED_STYLE = './ps-mixed-const.component.css'; @@ -1994,7 +2086,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).toContain('PS_MIXED_CONST_MARKER') }) - it('falls back when a `styleUrl` template literal is interpolated', async () => { + it('serves the interpolated `styleUrl` of a class, folded like the compiler folds it', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2002,7 +2094,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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 + // The extractor folds this to ./ps-interp.component.css; the raw source // slice `${DIR}/ps-interp.component.css` is not a real path. const source = ` import { Component } from '@angular/core'; @@ -2033,9 +2125,9 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // Quoted metadata keys are valid TS, and the extractor resolves them + // (verified: `'styleUrls'` reports its URL). Reading the key as absent + // would make 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) @@ -2126,10 +2218,10 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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 () => { + // A computed key whose value is a same-file string const still resolves in + // the extractor (verified: it reports ./ps-computed-own.component.css), so + // the class is served its own stylesheet directly. + it('serves the own stylesheet of a class whose style field has a computed key', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2213,8 +2305,7 @@ describe('@ng/component endpoint resolves the styles per class', () => { // 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. + // the one path it spells out, and that is what it must be served. it('serves only the spelled-out entry of a styleUrls array with a spread', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2272,8 +2363,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // is what the extractor reports and what the compiler resolves. A raw + // source slice yields a path that does not exist. const source = ` import { Component } from '@angular/core'; @Component({ @@ -2366,9 +2457,8 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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. + // A shorthand `styleUrl,` is a style field backed by a same-file const; + // the extractor folds it (verified: ./ps-shorthand-own.component.css). const source = ` import { Component } from '@angular/core'; const styleUrl = './ps-shorthand-own.component.css'; @@ -2403,10 +2493,10 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) 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. + // The sibling's stylesheet used to ride along here, because the endpoint + // fell back to the file-level union for any field it could not read. + // That was the #456 contamination; the per-class metadata removes it. + expect(body).not.toContain('PS_SHORTHAND_SIB_MARKER') }) // ---------------------------------------------------------------- @@ -2571,15 +2661,16 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).not.toContain('styles:') }) - it('keeps the styles of a class whose style field cannot be read', async () => { + it('serves `styles: []` for 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') - // `styles: PS457_STYLES` holds no string literal, so the text scan reports - // the field unreadable — the class's styles are UNKNOWN, not absent. There - // is no file-level `styleUrls` to fall back to either. Clearing here would - // wipe the CSS the Rust extractor folded out of the constant. + // `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. const source = ` import { Component } from '@angular/core'; const PS457_STYLES = ['.PS457_CONST_MARKER { color: red; }']; @@ -2605,9 +2696,10 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('styles:') + expect(body).not.toContain('PS457_CONST_MARKER') + expect(body).toContain('styles: []') }) - it('keeps the styles of a class with an unreadable style field when the file-level stylesheet is empty', async () => { + it('serves `styles: []` for an array-constant `styles` beside an empty sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) @@ -2618,11 +2710,10 @@ describe('@ng/component endpoint resolves the styles per class', () => { writeFileSync(fbEmptyCssPath, '') // Two classes. `FbEmptyOwnerComponent` declares `styles: PS457_FB_STYLES`, - // which the text scan cannot read, so the endpoint falls back to the - // FILE-LEVEL `styleUrls` union — and that union holds only the SIBLING's - // stylesheet. The fallback knows nothing about this class's decorator, so - // an empty read there is "no answer", never "no styles"; clearing on it - // would wipe the CSS the Rust extractor folded out of the constant. + // 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. const source = ` import { Component } from '@angular/core'; const PS457_FB_STYLES = ['.PS457_FB_MARKER { color: red; }']; @@ -2654,17 +2745,18 @@ describe('@ng/component endpoint resolves the styles per class', () => { ) expect(body).not.toBe('') expect(body).toContain('two') - expect(body).not.toContain('styles:') + expect(body).not.toContain('PS457_FB_MARKER') + expect(body).toContain('styles: []') }) - it('keeps the styles of a class with an unreadable style field when the file-level stylesheet is whitespace', async () => { + it('serves `styles: []` for an array-constant `styles` beside a whitespace sibling stylesheet', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) const fbWsCssPath = join(appDir, 'ps457-fb-ws.component.css') const fbWsPath = join(appDir, 'ps457-fb-ws.component.ts') // Whitespace-only reads back as a whitespace-only string, which the - // binding drops just like an empty one — same trap, one step later. + // binding drops just like an empty one — same sibling, one step later. writeFileSync(fbWsCssPath, '\n \n\t\n') const source = ` @@ -2698,7 +2790,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).not.toContain('PS457_FB_WS_MARKER') + expect(body).toContain('styles: []') }) // A read that FAILED on one stylesheet but succeeded on another is not the @@ -2834,4 +2927,440 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).toContain('two') expect(body).not.toContain('styles:') }) + + // ---------------------------------------------------------------- + // Issue #456 — styles the compiler folds but a text scan cannot read + // ---------------------------------------------------------------- + // A constant, an interpolated template literal or a constant mixed into an + // array holds no readable string literal in the decorator TEXT, so the old + // endpoint declared the class's styles unknown and served the FILE-LEVEL + // union instead. In every fixture below a styled SIBLING is declared first, + // so that union carried its stylesheet straight onto this class. + // + // `extract_component_metadata` folds all of these — it is the same call, + // with the same `collect_string_consts` table, that `transform.rs` uses to + // build the `\u0275cmp` — so the per-class answer is now definitive. + + it('serves the own stylesheet of a class whose `styleUrls` entry is interpolated', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const themesDir = join(appDir, 'themes') + mkdirSync(themesDir, { recursive: true }) + const ownCssPath = join(themesDir, 'ps456-dir-own.component.css') + const sibCssPath = join(appDir, 'ps456-dir-sib.component.css') + const dirPath = join(appDir, 'ps456-dir.component.ts') + writeFileSync(ownCssPath, '.PS456_DIR_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS456_DIR_SIB_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + const PS456_DIR = './themes'; + @Component({ + selector: 'app-ps456-dir-sib', + template: '

sib

', + styleUrls: ['./ps456-dir-sib.component.css'], + }) + export class DirSiblingComponent {} + @Component({ + selector: 'app-ps456-dir-own', + template: '

own

', + styleUrls: [\`\${PS456_DIR}/ps456-dir-own.component.css\`], + }) + export class DirOwnComponent {} + ` + writeFileSync(dirPath, source) + await transformSource(plugin, source, dirPath) + + writeFileSync(ownCssPath, '.PS456_DIR_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${dirPath}@DirOwnComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS456_DIR_OWN_MARKER') + expect(body).toContain('green') + expect(body).not.toContain('PS456_DIR_SIB_MARKER') + }) + + it('serves both entries when a `styleUrls` array mixes a constant with its own literal', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sharedCssPath = join(appDir, 'ps456-shared.component.css') + const ownCssPath = join(appDir, 'ps456-mix-own.component.css') + const sibCssPath = join(appDir, 'ps456-mix-sib.component.css') + const mixPath = join(appDir, 'ps456-mix.component.ts') + writeFileSync(sharedCssPath, '.PS456_SHARED_MARKER { color: red; }') + writeFileSync(ownCssPath, '.PS456_MIX_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS456_MIX_SIB_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + const PS456_SHARED = './ps456-shared.component.css'; + @Component({ + selector: 'app-ps456-mix-sib', + template: '

sib

', + styleUrls: ['./ps456-mix-sib.component.css'], + }) + export class MixSiblingComponent {} + @Component({ + selector: 'app-ps456-mix-own', + template: '

own

', + styleUrls: [PS456_SHARED, './ps456-mix-own.component.css'], + }) + export class MixOwnComponent {} + ` + writeFileSync(mixPath, source) + await transformSource(plugin, source, mixPath) + + writeFileSync(ownCssPath, '.PS456_MIX_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${mixPath}@MixOwnComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS456_SHARED_MARKER') + expect(body).toContain('PS456_MIX_OWN_MARKER') + expect(body).not.toContain('PS456_MIX_SIB_MARKER') + // Resolved in declaration order, the order `resolve_styles` appends them. + expect(body.indexOf('PS456_SHARED_MARKER')).toBeLessThan(body.indexOf('PS456_MIX_OWN_MARKER')) + }) + + it('serves the folded inline `styles` of a class beside a styled sibling', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = join(appDir, 'ps456-inline-sib.component.css') + const inlinePath = join(appDir, 'ps456-inline.component.ts') + writeFileSync(sibCssPath, '.PS456_INLINE_SIB_MARKER { color: blue; }') + + // A STRING const — the kind `collect_string_consts` folds — used as the + // one entry of an inline `styles` array. + const source = ` + import { Component } from '@angular/core'; + const PS456_INLINE_STYLE = '.PS456_INLINE_OWN_MARKER { color: red; }'; + @Component({ + selector: 'app-ps456-inline-sib', + template: '

sib

', + styleUrls: ['./ps456-inline-sib.component.css'], + }) + export class InlineSiblingComponent {} + @Component({ + selector: 'app-ps456-inline-own', + template: '

one

', + styles: [PS456_INLINE_STYLE], + }) + export class InlineOwnComponent {} + ` + writeFileSync(inlinePath, source) + await transformSource(plugin, source, inlinePath) + + const edited = source.replace('

one

', '

two

') + writeFileSync(inlinePath, edited) + const ctx = createMockHmrContext(inlinePath, [{ id: inlinePath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + expectDispatched(mockServer, `${inlinePath}@InlineOwnComponent`) + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${inlinePath}@InlineOwnComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + expect(body).toContain('PS456_INLINE_OWN_MARKER') + expect(body).not.toContain('PS456_INLINE_SIB_MARKER') + }) + + // The invariant the whole design rests on: what the metadata resolves IS + // what the component compiles with. This test pins both sides at once — + // it compares the styles the plugin's own transform baked into the class's + // `\u0275cmp` against the styles the endpoint serves for that class. If the + // extractor and the compiler ever disagree, one of these two halves moves + // and the test fails. + it('serves exactly the styles the compiler resolved for a const-folded `styleUrls`', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const themesDir = join(appDir, 'themes') + mkdirSync(themesDir, { recursive: true }) + const ownCssPath = join(themesDir, 'ps456-inv-own.component.css') + const sibCssPath = join(appDir, 'ps456-inv-sib.component.css') + const invPath = join(appDir, 'ps456-inv.component.ts') + writeFileSync(ownCssPath, '.PS456_INV_OWN_MARKER { color: red; }') + writeFileSync(sibCssPath, '.PS456_INV_SIB_MARKER { color: blue; }') + + const source = ` + import { Component } from '@angular/core'; + const PS456_INV_DIR = './themes'; + @Component({ + selector: 'app-ps456-inv-sib', + template: '

sib

', + styleUrls: ['./ps456-inv-sib.component.css'], + }) + export class InvSiblingComponent {} + @Component({ + selector: 'app-ps456-inv-own', + template: '

own

', + styleUrls: [\`\${PS456_INV_DIR}/ps456-inv-own.component.css\`], + }) + export class InvOwnComponent {} + ` + writeFileSync(invPath, source) + const transformed = await transformSource(plugin, source, invPath) + + // What the COMPILER produced for this class. Everything from its own + // `class` keyword to the end of the emitted file belongs to it: the + // sibling is declared first, so nothing of the sibling's follows. + const compiled = transformed?.code ?? '' + const ownStart = compiled.indexOf('class InvOwnComponent') + expect(ownStart, 'expected the transform to emit InvOwnComponent').toBeGreaterThan(-1) + const compiledOwn = compiled.slice(ownStart) + expect(compiledOwn).toContain('PS456_INV_OWN_MARKER') + expect(compiledOwn).not.toContain('PS456_INV_SIB_MARKER') + + // What the ENDPOINT serves for the same class must agree. + writeFileSync(ownCssPath, '.PS456_INV_OWN_MARKER { color: green; }') + const ctx = createMockHmrContext( + normalizePath(ownCssPath), + [{ id: normalizePath(ownCssPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${invPath}@InvOwnComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('PS456_INV_OWN_MARKER') + expect(body).not.toContain('PS456_INV_SIB_MARKER') + }) + + // ---------------------------------------------------------------- + // The endpoint re-parses the file from DISK; `transform` compiled the + // `code` Vite handed it. Those are different byte streams whenever an + // 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 + // 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 () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const arrHtmlPath = join(appDir, 'ps458-arr.component.html') + 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. + const diskSource = ` + import { Component } from '@angular/core'; + const PS458_ARR = ['.PS458_ARR_MARKER { color: red; }']; + @Component({ + selector: 'app-ps458-arr', + templateUrl: './ps458-arr.component.html', + styles: PS458_ARR, + }) + export class ArrConstComponent {} + ` + writeFileSync(arrPath, diskSource) + + // What VITE HANDS `transform`: an upstream rewrite already expanded the + // reference, so the compiled component really does carry the style. + const transformCode = diskSource.replace( + 'styles: PS458_ARR', + `styles: ['.PS458_ARR_MARKER { color: red; }']`, + ) + const transformed = await transformSource(plugin, transformCode, arrPath) + expect(transformed?.code ?? '', 'expected the compile to carry the style').toContain( + 'PS458_ARR_MARKER', + ) + + // Edit the external template alone — nothing about the styles changed. + writeFileSync(arrHtmlPath, '

two

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(arrHtmlPath), + [{ id: normalizePath(arrHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${arrPath}@ArrConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + expect(body, 'an unreadable `styles` must stay unknown, not clear the live CSS').not.toContain( + 'styles:', + ) + }) + + it('keeps the styles of a class whose disk `styles` names an imported constant', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const impHtmlPath = join(appDir, 'ps458-imp.component.html') + const impPath = join(appDir, 'ps458-imp.component.ts') + writeFileSync(impHtmlPath, '

one

') + writeFileSync( + join(appDir, 'ps458-imp-styles.ts'), + `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. + const diskSource = ` + import { Component } from '@angular/core'; + import { PS458_IMP } from './ps458-imp-styles'; + @Component({ + selector: 'app-ps458-imp', + templateUrl: './ps458-imp.component.html', + styles: PS458_IMP, + }) + export class ImportedConstComponent {} + ` + writeFileSync(impPath, diskSource) + + const transformCode = diskSource.replace( + 'styles: PS458_IMP', + `styles: ['.PS458_IMP_MARKER { color: red; }']`, + ) + const transformed = await transformSource(plugin, transformCode, impPath) + expect(transformed?.code ?? '').toContain('PS458_IMP_MARKER') + + writeFileSync(impHtmlPath, '

two

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(impHtmlPath), + [{ id: normalizePath(impHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${impPath}@ImportedConstComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + expect(body).not.toContain('styles:') + }) + + it('keeps the styles of a class whose disk `styles` is a `.concat(...)` call', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const catHtmlPath = join(appDir, 'ps458-cat.component.html') + 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: []`. + const diskSource = ` + import { Component } from '@angular/core'; + const PS458_CAT_BASE = ['.PS458_CAT_MARKER { color: red; }']; + @Component({ + selector: 'app-ps458-cat', + templateUrl: './ps458-cat.component.html', + styles: PS458_CAT_BASE.concat(['.PS458_CAT_EXTRA { color: blue; }']), + }) + export class ConcatStylesComponent {} + ` + writeFileSync(catPath, diskSource) + + const transformCode = diskSource.replace( + `styles: PS458_CAT_BASE.concat(['.PS458_CAT_EXTRA { color: blue; }']),`, + `styles: ['.PS458_CAT_MARKER { color: red; }', '.PS458_CAT_EXTRA { color: blue; }'],`, + ) + const transformed = await transformSource(plugin, transformCode, catPath) + expect(transformed?.code ?? '').toContain('PS458_CAT_MARKER') + + writeFileSync(catHtmlPath, '

two

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(catHtmlPath), + [{ id: normalizePath(catHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${catPath}@ConcatStylesComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + expect(body).not.toContain('styles:') + }) + + // The counterweight: when the endpoint IS looking at the source the + // component compiled from, a genuinely styleless class must still clear. + // The gate adds evidence for the destructive answer; it does not retire it. + it('still clears via the external-resource path when the disk source is the compiled one', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sameHtmlPath = join(appDir, 'ps458-same.component.html') + const samePath = join(appDir, 'ps458-same.component.ts') + writeFileSync(sameHtmlPath, '

one

') + + // Disk and transform code are byte-identical, and the class has already + // lost its last inline style. Nothing is unknown here. + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-ps458-same', + templateUrl: './ps458-same.component.html', + styles: [], + }) + export class SameSourceComponent {} + ` + writeFileSync(samePath, source) + await transformSource(plugin, source, samePath) + + writeFileSync(sameHtmlPath, '

two

') + await callHandleHotUpdate( + plugin, + createMockHmrContext( + normalizePath(sameHtmlPath), + [{ id: normalizePath(sameHtmlPath) }], + mockServer, + ), + ) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${samePath}@SameSourceComponent`, + ) + expect(body).not.toBe('') + expect(body).toContain('two') + expect(body, 'a definitively styleless class must still be cleared').toContain('styles: []') + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 5cf474b21..0acf9bb74 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -23,6 +23,7 @@ const debugTransform = createDebug('vite:oxc-angular:transform') import { transformAngularFile, + extractComponentMetadataSync, extractComponentUrls, encapsulateStyle, compileForHmrSync, @@ -38,13 +39,8 @@ import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js' import { emptyDelimitedRange, locateComponentDecorators, - locateStylesFieldFor, - locateStyleFieldsFor, locateStylesInArgs, locateTemplateInArgs, - locateTemplateStringFor, - locateTemplateUrlFor, - readStringLiterals, } from './utils/decorator-fields.js' import { injectDtsDeclarations } from './utils/dts.js' @@ -385,11 +381,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { // it to decide whether to serve the update module or an empty response. const pendingHmrUpdates = new Set() - // Per-component caches keyed by `filePath@ClassName`. A multi-component file - // contributes one entry per component to each map. - const inlineTemplateCache = new Map() - const inlineStylesCache = new Map() - // Cache the source of each component .ts file with its `template:` and // `styles:` decorator fields stripped. If the stripped form is byte-identical // before and after a save, we know only the template / styles changed and @@ -653,17 +644,34 @@ export function angular(options: PluginOptions = {}): Plugin[] { try { const source = await readFile(resolvedId, 'utf-8') - const { templateUrls, styleUrls } = await extractComponentUrls(source, resolvedId) const dir = dirname(resolvedId) - // Read fresh template content (bypass cache for HMR). Resolve - // it per CLASS: in a multi-component file, templateUrls[0] is - // the FILE's first external template, which may belong to a - // sibling — serving it would replace this class's template with - // the sibling's markup. Prefer the requested class's own - // templateUrl, then its inline template; fall back to - // templateUrls[0] only for decorator shapes the per-class - // locator cannot parse (preserves the old behavior there). + // Resolve this class's resources from the SAME extractor the + // compiler runs. `extract_component_metadata` — with the same + // `collect_string_consts` table — is what `transform.rs` calls + // to build the `ɵcmp`, so `templateUrl` / `template` / + // `styleUrls` / `styles` here ARE the ones the component + // compiles with: same-file constants folded, template literals + // interpolated, quoted and computed keys resolved. + // + // That makes a per-class answer definitive, which retires the + // file-level fallback this endpoint used to need. The union of + // every component's URLs in the file was only ever a guess for + // decorator shapes a text scan could not read, and in a + // multi-component file it served a class its SIBLINGS' template + // and stylesheets (#456). + // + // A class MISSING from the metadata is equally definitive: the + // compiler skipped it too — no `ɵfac`, no `ɵcmp` — so there is + // nothing to hot-update. (`componentsByFile` already filtered + // those out above; it is built from classes that compiled.) + const classMetadata = extractComponentMetadataSync(source, resolvedId).find( + (candidate) => candidate.className === className, + ) + + // Read fresh template content (bypass cache for HMR), from the + // requested class's own `templateUrl`, else its own inline + // `template`. const readTemplate = async (url: string) => { const templatePath = resolve(dir, url) let content = await readFile(templatePath, 'utf-8') @@ -673,30 +681,17 @@ export function angular(options: PluginOptions = {}): Plugin[] { return content } let templateContent: string | null = null - const classTemplateUrl = extractTemplateUrlFor(source, className) - if (classTemplateUrl !== null) { - templateContent = await readTemplate(classTemplateUrl) - } else { - templateContent = extractInlineTemplate(source, className) - if (templateContent === null && templateUrls.length > 0) { - templateContent = await readTemplate(templateUrls[0]) - } + if (classMetadata?.templateUrl != null) { + templateContent = await readTemplate(classMetadata.templateUrl) + } else if (classMetadata?.template != null) { + templateContent = classMetadata.template } - if (templateContent) { - // Read fresh style content. External styleUrls are read from - // disk and run through Vite's preprocessCSS (so SCSS/LESS - // resolve correctly); inline styles are extracted from the - // .ts source as plain CSS strings. - // - // 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). + if (classMetadata && templateContent) { + // Read fresh style content. The class's own `styleUrls` are + // read from disk and run through Vite's preprocessCSS (so + // SCSS/LESS resolve correctly); its own inline `styles` come + // straight from the metadata as plain CSS strings. // // A read FAILURE is not evidence of stylelessness, so it has // to stay distinguishable from a successful read of an empty @@ -740,57 +735,72 @@ export function angular(options: PluginOptions = {}): Plugin[] { // whatever the component already has survives. `null` is the // default because "we did not find out" is the honest starting // point. - let styles: string[] | null = null - const classStyles = extractClassStylesFor(source, className) - if (classStyles !== null) { - // Inline `styles` first, then the resolved `styleUrl(s)` - // content appended — the order `resolve_styles` produces in - // the compiler, which pushes resolved content onto the - // decorator's own `styles`. Whitespace-only entries are - // dropped to match Angular's `style.trim().length > 0`. - const external = - classStyles.urls.length > 0 - ? await readStyles(classStyles.urls) - : { contents: [] as string[], complete: true } - const merged = [...classStyles.inline, ...external.contents].filter( - (style) => style.trim().length > 0, + // + // Inline `styles` first, then the resolved `styleUrl(s)` + // content appended — the order `resolve_styles` produces in + // the compiler, which pushes resolved content onto the + // decorator's own `styles`. Whitespace-only entries are + // dropped to match Angular's `style.trim().length > 0`. + const external = + classMetadata.styleUrls.length > 0 + ? await readStyles(classMetadata.styleUrls) + : { contents: [] as string[], complete: true } + const merged = [...classMetadata.styles, ...external.contents].filter( + (style) => style.trim().length > 0, + ) + // `complete` guards exactly ONE thing: turning an unknown + // answer into a definitive `[]` that wipes live CSS. It is + // not a reason to throw away content that WAS read. A + // stylesheet that failed while a sibling succeeded is a + // PARTIAL update — which is what main always delivered, + // because the old `readStyles` swallowed failures in a + // `catch` and returned whatever it got. Dropping it would + // silently lose every edit to the healthy sibling of a + // permanently unreadable stylesheet, and the pending slot is + // consumed either way, so nothing retries. + // + // So: a complete read is definitive and may clear; an + // incomplete one falls back to main's rule exactly — send + // what was read when it is non-empty, and never send `[]`. + // + // `complete` speaks only about the filesystem. One more + // thing has to hold before an empty answer may CLEAR, and it + // is about the SOURCE: this endpoint re-parses `resolvedId` + // from disk, while the component was compiled from the `code` + // Vite handed `transform`. Those differ whenever another + // plugin's `load` / pre-`transform` rewrote the module, or + // `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 + // as definitive that emits `styles: []` and wipes CSS the + // running component genuinely has, from a template edit that + // never touched the styles at all. + // + // `componentMetadataCache` already holds the transform-time + // source with the `template:` / `styles:` field VALUES + // blanked, and blanking only ever empties a DELIMITED range — + // so an expression the strip cannot open survives verbatim + // and the two stripped forms disagree exactly when the two + // sources disagree outside those fields. Matching strips is + // the evidence that the styles read here are the styles the + // component compiled with. + // + // This gates the destructive answer ONLY. Content that WAS + // read is still served on a mismatch — no worse than main, + // which scanned the same disk source. + const compiledFromThisSource = () => { + const cachedStripped = componentMetadataCache.get(resolvedId) + return ( + cachedStripped !== undefined && + cachedStripped === stripComponentMetadata(source) ) - // `complete` guards exactly ONE thing: turning an unknown - // answer into a definitive `[]` that wipes live CSS. It is - // not a reason to throw away content that WAS read. A - // stylesheet that failed while a sibling succeeded is a - // PARTIAL update — which is what main always delivered, - // because the old `readStyles` swallowed failures in a - // `catch` and returned whatever it got. Dropping it would - // silently lose every edit to the healthy sibling of a - // permanently unreadable stylesheet, and the pending slot is - // consumed either way, so nothing retries. - // - // So: a complete read is definitive and may clear; an - // incomplete one falls back to main's rule exactly — send - // what was read when it is non-empty, and never send `[]`. - styles = external.complete || merged.length > 0 ? merged : null - } else if (styleUrls.length > 0) { - // The class's own styles could not be read — a decorator - // shape this text scan cannot parse, e.g. a styleUrl built - // from a constant the Rust extractor folds. Fall back to the - // file-level list, preserving the old behavior there. This - // branch never clears: the decorator is unknown, so an empty - // read here means "no answer", not "no styles". - // - // Which is why the whitespace-only filter has to run BEFORE - // the null check, not after the call like the branch above. - // A stylesheet that reads fine but holds nothing yields - // `['']` — one entry, so a bare `length > 0` would pass it - // on, and the binding, which treats any non-null array as - // definitive and drops whitespace-only entries, would turn - // it into `styles: []` and clear. That is the opposite of - // what this branch promises, and the CSS it would wipe - // belongs to a decorator nobody here could read. - const fallback = await readStyles(styleUrls) - const usable = fallback.contents.filter((style) => style.trim().length > 0) - styles = usable.length > 0 ? usable : null } + const styles: string[] | null = + merged.length > 0 || (external.complete && compiledFromThisSource()) + ? merged + : null const result = compileForHmrSync(templateContent, className, resolvedId, styles, { angularVersion: pluginOptions.angularVersion, @@ -1017,18 +1027,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (atIdx === -1) continue classNamesInFile.add(componentId.slice(atIdx + 1)) } - // Prune cache entries for components that USED to be in this file - // but no longer are (e.g. a class was renamed or removed). Without - // this, the HMR endpoint could find a stale `pendingHmrUpdates` - // entry pointing at a className that's gone, fail to extract a - // template for it, and orphan the slot forever. + // Prune pending updates for components that USED to be in this + // file but no longer are (e.g. a class was renamed or removed). + // Without this, the HMR endpoint could find a stale + // `pendingHmrUpdates` entry pointing at a className that's gone, + // resolve no metadata for it, and orphan the slot forever. const previouslyInFile = componentsByFile.get(actualId) if (previouslyInFile) { for (const oldClass of previouslyInFile) { if (classNamesInFile.has(oldClass)) continue const staleKey = `${actualId}@${oldClass}` - inlineTemplateCache.delete(staleKey) - inlineStylesCache.delete(staleKey) pendingHmrUpdates.delete(staleKey) debugHmr('pruned stale cache entries for %s', staleKey) } @@ -1039,25 +1047,8 @@ export function angular(options: PluginOptions = {}): Plugin[] { debugHmr('registered: %s -> %s', actualId, className) } - // Cache per-component inline template / styles for detecting - // template/styles-only changes in handleHotUpdate, and the - // metadata-stripped (whole-file) source for cheaply diffing - // whether anything else changed. - for (const className of classNamesInFile) { - const cacheKey = `${actualId}@${className}` - const inlineTemplate = extractInlineTemplate(code, className) - if (inlineTemplate !== null) { - inlineTemplateCache.set(cacheKey, inlineTemplate) - } else { - inlineTemplateCache.delete(cacheKey) - } - const inlineStyles = extractInlineStyles(code, className) - if (inlineStyles !== null) { - inlineStylesCache.set(cacheKey, inlineStyles) - } else { - inlineStylesCache.delete(cacheKey) - } - } + // Cache the metadata-stripped (whole-file) source for cheaply + // diffing whether anything besides template/styles changed. componentMetadataCache.set(actualId, stripComponentMetadata(code)) } @@ -1308,22 +1299,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { const newStripped = stripComponentMetadata(newContent) if (newStripped === cachedStripped) { debugHmr('inline template/styles-only change, dispatching HMR for %s', ctx.file) - // Refresh per-component caches with the new contents. - for (const className of fileClassNames) { - const cacheKey = `${ctx.file}@${className}` - const newTemplate = extractInlineTemplate(newContent, className) - if (newTemplate !== null) { - inlineTemplateCache.set(cacheKey, newTemplate) - } else { - inlineTemplateCache.delete(cacheKey) - } - const newStyles = extractInlineStyles(newContent, className) - if (newStyles !== null) { - inlineStylesCache.set(cacheKey, newStyles) - } else { - inlineStylesCache.delete(cacheKey) - } - } componentMetadataCache.set(ctx.file, newStripped) // Conservatively dispatch HMR for every component in the file — // Angular's runtime no-ops if a component's metadata didn't @@ -1479,86 +1454,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { ].filter(Boolean) as Plugin[] } -/** - * Extract the inline template from the `@Component({...})` decorator that - * decorates the class named `className`. Returns null if no such decorator - * exists or the decorator has no inline `template:` string literal. - */ -function extractInlineTemplate(code: string, className: string): string | null { - const range = locateTemplateStringFor(code, className) - if (!range) return null - // Slice excludes the outer quotes/backticks — raw inner contents. - return code.slice(range[0] + 1, range[1]) -} - -/** - * Extract the `templateUrl` string from the `@Component({...})` decorator - * that decorates the class named `className`. Returns null if no such - * decorator exists or the decorator has no `templateUrl:` string literal. - */ -function extractTemplateUrlFor(code: string, className: string): string | null { - const range = locateTemplateUrlFor(code, className) - if (!range) return null - // Slice excludes the outer quotes/backticks — raw inner contents. - return code.slice(range[0] + 1, range[1]) -} - -/** - * The styles one class declares in its own `@Component({...})`, split by - * source: inline `styles` and external `styleUrl(s)`. - * - * Handles every shape Angular accepts — `styles` and `styleUrls` as an array - * or a bare literal, plus the singular `styleUrl` (17+). Both arrays are - * empty for a class that declares no styles at all, which is a real answer: - * that component must be served nothing. - * - * Returns null when the answer is *unknown* rather than empty — the class's - * decorator could not be located, or it declares a style field whose value - * holds no string literal (a constant, an identifier, an unresolved - * interpolation). The caller falls back to the file-level list there, since - * the Rust extractor folds constants this text scan cannot. - */ -function extractClassStylesFor( - code: string, - className: string, -): { inline: string[]; urls: string[] } | null { - const fields = locateStyleFieldsFor(code, className) - if (!fields) return null - - const read = (field: (typeof fields)['inline']): string[] | null => { - if (field.kind === 'absent') return [] - if (field.kind === 'unreadable') return null - const { literals, complete } = readStringLiterals(code, field.range) - // A partial read is unknown, not partial knowledge: acting on the - // literals alone would drop whatever the unreadable elements name. - return complete ? literals : null - } - const inline = read(fields.inline) - const urls = read(fields.urls) - if (inline === null || urls === null) return null - return { inline, urls } -} - -/** - * Extract the inline styles from the `@Component({...})` decorator that - * decorates the class named `className`, as a positional array. - * - * Handles both Angular forms — `styles: string | string[]`: - * - Array of literals (`['…']`, `["…"]`, `` [`…`] ``, or any mix) → each - * literal becomes one element, preserving order (HMR delivery is positional). - * - Bare single literal (`'…'`, `"…"`, or `` `…` ``) → returned as a - * one-element array. - * - * Returns null if the named decorator has no `styles:` field or its value is - * something other than a string/array literal (e.g. a variable reference). - */ -function extractInlineStyles(code: string, className: string): string[] | null { - const range = locateStylesFieldFor(code, className) - if (!range) return null - const { literals } = readStringLiterals(code, range) - return literals.length > 0 ? literals : null -} - /** * Empty the `template:` and `styles:` field values of *every* `@Component(...)` * in the source, returning the result. Used to detect "only template/styles diff --git a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts index ddea7b3fd..20a2bf641 100644 --- a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts +++ b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts @@ -1,6 +1,11 @@ /** * Helpers for locating inline `@Component` decorator fields in source text. * + * These back the metadata *strip* that decides HMR versus full reload: + * emptying every `@Component`'s `template:` and `styles:` field and checking + * the result for byte equality across a save. Per-class resource resolution + * comes from the Rust extractor, not from here. + * * Regex-based extraction is unreliable here because the field bodies can * contain the closing delimiter we'd otherwise rely on — for example, a * styles array body commonly contains `]` characters inside attribute @@ -21,11 +26,11 @@ * literal `@Component` form is recognized. * - **Parenthesized decorator expressions** like `@(Component as any)(...)` * — uncommon and not supported. - * - **Computed property keys** (`{ ['styles']: [...] }`) and quoted keys - * carrying an escape (`{ 'style\u0055rls': [...] }`) can't be resolved - * to a name here. Plain quoted keys (`{ 'styles': [...] }`) are matched; - * the unresolvable forms are reported so the caller can fall back rather - * than read them as a field that isn't there. + * - **Computed property keys** (`{ ['styles']: [...] }`) can't be resolved + * to a name here and so never match a field. Quoted keys are matched, + * including ones written with a decodable escape + * (`{ 'style\u0073': [...] }`); a key whose escapes cannot be decoded + * does not match. * - **Concatenated style strings** inside an array (`styles: ['a' + 'b']`) * are extracted as two separate elements; cosmetic but harmless because * the browser sees the same CSS either way. @@ -213,12 +218,11 @@ export function emptyDelimitedRange(code: string, range: [number, number]): stri * missing entirely, present with a value this scan can read, or present with * a value it cannot (an identifier, a call, a concatenation). * - * The third state is why this exists. Collapsing it into "absent" tells a - * caller the component declares nothing, when in truth the value is simply - * beyond a text scan — the Rust extractor folds same-file constants that this - * cannot. Those two need opposite fallbacks. + * The third state stays apart from "absent" because the two end the walk + * differently: an unreadable value IS this field, so the search stops there, + * while an absent key means the search keeps going. */ -export type FieldValue = +type FieldValue = | { kind: 'absent' } | { kind: 'unreadable' } | { kind: 'literal'; range: [number, number] } @@ -279,12 +283,11 @@ function findFieldInArgs( if (v < closeParen && openerChars.includes(code[v])) { const end = findClosingDelim(code, v) // The literal is the value only when the property ENDS at its - // closing delimiter. `'./a.css' + SUFFIX` opens with a literal - // it does not denote, and so do `['./a.css'].concat(MORE)` and - // `'./a.css' as const`. Returning that leading piece would name - // a stylesheet the compiled component never had — and, being a - // confident answer, would skip the fallback that exists for - // values this scan cannot resolve. + // closing delimiter. `'

' + SUFFIX` opens with a literal it + // does not denote, and so do `['.a{}'].concat(MORE)` and + // `'.a{}' as const`. Returning that leading piece would empty a + // range the real value extends past, so the strip would no + // longer be byte-identical for an unrelated edit. if (end !== -1 && end < closeParen && endsPropertyValue(code, end + 1, closeParen)) { return { kind: 'literal', range: [v, end] } } @@ -292,11 +295,11 @@ function findFieldInArgs( // The key is here; its value is not a shape we can read. return { kind: 'unreadable' } } - // Shorthand (`{ styleUrl }`): the key stands alone, so the value is + // Shorthand (`{ styles }`): the key stands alone, so the value is // a binding this scan cannot follow. Whether that is unknowable or - // genuinely nothing depends on the field — see `locateStyleFieldsFor`. - // Anything else after the key (`(` for a method or accessor) is not - // this field at all, so keep scanning. + // genuinely nothing depends on the field, which is what + // `shorthandMeans` says. Anything else after the key (`(` for a + // method or accessor) is not this field at all, so keep scanning. if ((code[j] === ',' || code[j] === '}') && shorthandMeans === 'unreadable') { return { kind: 'unreadable' } } @@ -357,11 +360,10 @@ function locateFieldInsideArgs( * (`style\u0055rls`, `'style\u0055rls'`) — all of which are valid TS naming * the same field, and all of which the Rust extractor resolves. * - * Escapes are decoded rather than refused. Refusing them would only be safe - * in the sense of falling back, and the fallback serves the file-level style - * union — every sibling's CSS along with this class's own. Decoding keeps the - * per-class answer exact. A key whose escapes cannot be decoded is not - * matched here; `hasUnreadableKey` reports it as beyond this scan. + * Escapes are decoded rather than refused: decoding keeps the match exact + * where refusing would silently miss a field that is really there. A key + * whose escapes cannot be decoded is not matched here, so the field reads + * as absent. * * Decoding does not loosen the match: the decoded name must still equal * `field` exactly, so `styleUrl` and `styleUrls` stay distinct. @@ -601,14 +603,14 @@ function findClassName(code: string, start: number, end: number): string | null /** * Locate the `styles:` field inside a specific `@Component(...)` decorator - * identified by its `argsRange`. Use this when you already have a - * `ComponentDecorator` in hand (e.g. while iterating - * `locateComponentDecorators(code)`); it avoids re-enumerating decorators - * on every lookup, which is the difference between O(N) and O(N²) for - * files with N components. + * identified by its `argsRange`, which the caller already holds from + * iterating `locateComponentDecorators(code)` — so a multi-component file + * costs one decorator enumeration, not one per lookup. * - * Returns the inclusive `[start, end]` of the value's outer delimiters, - * or null if the decorator has no `styles:` field. + * Returns the inclusive `[start, end]` of the value's outer delimiters, or + * null if the decorator has no `styles:` field. The value may be an array + * literal (`[…]`) or a bare string (`'…'`, `"…"`, `` `…` ``) — Angular's + * `styles` is typed `string | string[]`. */ export function locateStylesInArgs( code: string, @@ -619,8 +621,8 @@ export function locateStylesInArgs( /** * Locate the `template:` string field inside a specific `@Component(...)` - * decorator identified by its `argsRange`. See `locateStylesInArgs` for - * when to prefer this over the className-based variant. + * decorator identified by its `argsRange`. Field matching is word-bounded, + * so a `templateUrl:` field is never read as `template:`. */ export function locateTemplateInArgs( code: string, @@ -629,114 +631,6 @@ export function locateTemplateInArgs( return locateFieldInsideArgs(code, argsRange, 'template', TEMPLATE_OPENERS) } -/** - * Locate the `styles:` field inside the `@Component(...)` decorator that - * decorates the class named `className`. Convenience wrapper that finds - * the decorator by className and delegates to `locateStylesInArgs`. The - * styles value can be an array literal (`[…]`) or a bare string (`'…'`, - * `"…"`, `` `…` ``) — Angular's `styles` is typed `string | string[]`. - */ -export function locateStylesFieldFor(code: string, className: string): [number, number] | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - return found ? locateStylesInArgs(code, found.argsRange) : null -} - -/** - * Locate the `template:` string field inside the `@Component(...)` decorator - * that decorates the class named `className`. Convenience wrapper that - * finds the decorator by className and delegates to `locateTemplateInArgs`. - */ -export function locateTemplateStringFor(code: string, className: string): [number, number] | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - return found ? locateTemplateInArgs(code, found.argsRange) : null -} - -/** - * Locate the `templateUrl:` string field inside a specific `@Component(...)` - * decorator identified by its `argsRange`. See `locateStylesInArgs` for - * when to prefer this over the className-based variant. Field matching is - * word-bounded, so `templateUrl` never matches a `template:` field and - * vice versa. - */ -export function locateTemplateUrlInArgs( - code: string, - argsRange: [number, number], -): [number, number] | null { - return locateFieldInsideArgs(code, argsRange, 'templateUrl', TEMPLATE_OPENERS) -} - -/** - * Locate the `templateUrl:` string field inside the `@Component(...)` - * decorator that decorates the class named `className`. Convenience wrapper - * that finds the decorator by className and delegates to - * `locateTemplateUrlInArgs`. - */ -export function locateTemplateUrlFor(code: string, className: string): [number, number] | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - return found ? locateTemplateUrlInArgs(code, found.argsRange) : null -} - -/** - * Locate the `styleUrls:` field inside a specific `@Component(...)` decorator - * identified by its `argsRange`. See `locateStylesInArgs` for when to prefer - * this over the className-based variant. Field matching is word-bounded, so - * `styleUrls` never matches the singular `styleUrl:` or the inline `styles:`. - */ -export function locateStyleUrlsInArgs( - code: string, - argsRange: [number, number], -): [number, number] | null { - return locateFieldInsideArgs(code, argsRange, 'styleUrls', STYLES_OPENERS) -} - -/** - * Locate the singular `styleUrl:` string field (Angular 17+) inside a - * specific `@Component(...)` decorator identified by its `argsRange`. Its - * value is a bare string, never an array — see `locateStyleUrlsInArgs` for - * the plural form. - */ -export function locateStyleUrlInArgs( - code: string, - argsRange: [number, number], -): [number, number] | null { - return locateFieldInsideArgs(code, argsRange, 'styleUrl', TEMPLATE_OPENERS) -} - -/** - * Locate the `styleUrls:` field inside the `@Component(...)` decorator that - * decorates the class named `className`. Convenience wrapper that finds the - * decorator by className and delegates to `locateStyleUrlsInArgs`. - */ -export function locateStyleUrlsFor(code: string, className: string): [number, number] | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - return found ? locateStyleUrlsInArgs(code, found.argsRange) : null -} - -/** - * Locate the singular `styleUrl:` field inside the `@Component(...)` - * decorator that decorates the class named `className`. Convenience wrapper - * that finds the decorator by className and delegates to - * `locateStyleUrlInArgs`. - */ -export function locateStyleUrlFor(code: string, className: string): [number, number] | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - return found ? locateStyleUrlInArgs(code, found.argsRange) : null -} - -/** - * Whether a template literal's raw contents contain an unescaped `${`, i.e. - * the literal interpolates and its text is not the final value. A leading - * run of backslashes escapes the `$` only when it is odd-length. - */ -function hasInterpolation(raw: string): boolean { - for (let i = raw.indexOf('${'); i !== -1; i = raw.indexOf('${', i + 2)) { - let backslashes = 0 - while (i - 1 - backslashes >= 0 && raw[i - 1 - backslashes] === '\\') backslashes++ - if (backslashes % 2 === 0) return true - } - return false -} - const SINGLE_CHAR_ESCAPES: Record = { n: '\n', t: '\t', @@ -845,248 +739,3 @@ function decodeEscapes(raw: string): string | null { } return out } - -/** - * The literals read out of a field value, and whether every element was read. - * - * `complete: false` means the value holds something this scan cannot resolve — - * an identifier, an interpolated template literal, an unterminated string. - * The literals gathered alongside it are NOT a partial answer to act on: a - * caller that used them would silently drop the stylesheets the unreadable - * elements stand for. Treat an incomplete read as unknown. - * - * A spread element (`...X`) and an elision are the exceptions: the Rust - * extractor drops both before resolving anything, so skipping them leaves - * the read complete. - * - * An empty array is the opposite case: `complete: true` with no literals is a - * definitive "this field declares nothing". - */ -export interface StringLiteralsRead { - literals: string[] - complete: boolean -} - -/** - * Read the string literals out of a located field value, given the inclusive - * `[start, end]` range of its outer delimiters (the shape every `locate*` - * here returns). - * - * Handles both shapes Angular accepts for `styles` / `styleUrls`: - * - Array (`['…', "…", `…`]`, or any mix) → each literal, in order. - * - Bare single literal (`'…'`, `"…"`, `` `…` ``) → a one-element array. - * - * Inside the array body, whitespace, commas and comments are skipped, and - * each literal is delimited with `findClosingDelim`, so escape sequences and - * apostrophes inside comments cannot be mistaken for delimiters. A spread - * element is skipped whole — see the branch below for why that keeps the - * read complete. - * - * Returns the raw inner contents — no unescaping, no trimming — because HMR - * delivers these verbatim. - */ -export function readStringLiterals(code: string, range: [number, number]): StringLiteralsRead { - const [start, end] = range - if (code[start] !== '[') { - // Bare literal — the range already delimits it. - const raw = code.slice(start + 1, end) - if (code[start] === '`' && hasInterpolation(raw)) return { literals: [], complete: false } - const decoded = decodeEscapes(raw) - return decoded === null - ? { literals: [], complete: false } - : { literals: [decoded], complete: true } - } - - const literals: string[] = [] - let complete = true - let i = start + 1 - while (i < end) { - const ch = code[i] - if (WS_RE.test(ch) || ch === ',') { - i++ - continue - } - const afterComment = skipComment(code, i, end) - if (afterComment !== -1) { - i = afterComment - continue - } - if (ch === "'" || ch === '"' || ch === '`') { - const close = findClosingDelim(code, i) - // Unterminated literal: nothing further can be read reliably. - if (close === -1 || close >= end) { - complete = false - break - } - const raw = code.slice(i + 1, close) - const decoded = ch === '`' && hasInterpolation(raw) ? null : decodeEscapes(raw) - if (decoded === null) { - complete = false - } else { - literals.push(decoded) - } - i = close + 1 - continue - } - if (ch === '.' && code[i + 1] === '.' && code[i + 2] === '.') { - // A spread element (`...X`). The Rust extractor asks each element for - // `as_expression()`, and `ArrayExpressionElement::is_expression()` - // enumerates neither `SpreadElement` nor `Elision` — so a spread is - // dropped BEFORE any value resolution, for every possible program, - // with the const table never consulted. Dropping it here too leaves - // the array complete and matches what the class compiles to; calling - // it unknown would hand the class its siblings' stylesheets instead. - // (An elision needs no branch: the comma skip above already reads - // past a hole.) Same reasoning as the spread in `hasUnreadableKey`. - // - // The whole element is skipped, so a literal nested inside it is - // never mistaken for one of this class's styles. `advanceOneToken` - // does the delimiter tracking, so nesting, strings, template - // interpolations and comments all hold a comma harmlessly. - const spreadStack: Ctx[] = [] - let j = i + 3 - while (j < end) { - if (spreadStack.length === 0 && code[j] === ',') break - j = advanceOneToken(code, j, spreadStack, end) - } - // Ran past the array's closer, or ended inside an unclosed string, - // comment or bracket: where this element stops is unknowable, so - // report unknown rather than guess. `j > i`, so `i` still advances. - if (j > end || spreadStack.length > 0) { - complete = false - break - } - i = j - continue - } - // An identifier, call or nested array: this element IS an expression, - // which the Rust resolver may fold from constants this scan cannot - // read, so the array as a whole is unknown. Every branch above advances - // `i`, so the scan terminates. - complete = false - i++ - } - return { literals, complete } -} - -/** - * The style-related fields of one `@Component(...)`, each classified by - * `FieldValue`: absent, unreadable, or a literal range. - */ -export interface ClassStyleFields { - /** `styleUrls: [...]`, else the singular `styleUrl: '...'`. */ - urls: FieldValue - /** Inline `styles: [...] | '...'`. */ - inline: FieldValue -} - -/** - * Classify the style fields of the `@Component(...)` decorating `className`. - * - * Returns null when no such decorator could be located at all. Otherwise each - * member says whether the class declares that field, and whether its value - * can be read — see `FieldValue`, and note that "declares nothing" and - * "cannot be read" need opposite fallbacks at the call site. - * - * `styleUrls` wins over `styleUrl` when a decorator carries both, unless the - * plural form is absent; Angular itself rejects that combination, so this only - * makes the choice deterministic. One decorator scan serves all three lookups. - * - * A shorthand property (`{ styleUrl }`) is unreadable for the singular form - * only. Measured against the Rust extractor: it folds a same-file string - * constant behind `styleUrl`, so calling that absent would strip real CSS — - * but it drops the array-valued `styleUrls` and `styles` shorthands, so the - * compiled component genuinely has no styles from them and falling back would - * hand the class its siblings' stylesheets. Same reasoning as the spread in - * `hasUnreadableKey`: match what the component actually compiles to. - */ -export function locateStyleFieldsFor(code: string, className: string): ClassStyleFields | null { - const found = locateComponentDecorators(code).find((d) => d.className === className) - if (!found) return null - // A key this scan cannot read could BE a style field, so "absent" would be - // a guess rather than an answer. Only absent is promoted: a field we did - // read says exactly what it says, whatever else the object holds. - const blind = hasUnreadableKey(code, found.argsRange) - const classify = (value: FieldValue): FieldValue => - blind && value.kind === 'absent' ? { kind: 'unreadable' } : value - - const plural = findFieldInArgs(code, found.argsRange, 'styleUrls', STYLES_OPENERS) - return { - urls: classify( - plural.kind === 'absent' - ? findFieldInArgs(code, found.argsRange, 'styleUrl', TEMPLATE_OPENERS, 'unreadable') - : plural, - ), - inline: classify(findFieldInArgs(code, found.argsRange, 'styles', STYLES_OPENERS)), - } -} - -/** - * Whether the decorator's own object literal holds a key this scan cannot - * resolve to a name, which makes "the class declares no styles" unknowable. - * - * Two forms qualify, so reading them as absent would strip a component's CSS: - * - a computed key (`[K]: [...]`), which the Rust extractor DOES resolve; - * - a key whose escapes cannot be decoded — a malformed `\u`, or an escape - * that is illegal in an identifier. A key whose escapes DO decode is not - * unreadable: `matchFieldKeyAt` resolves it to a name exactly. - * - * A spread (`...BASE`) deliberately does NOT qualify. The Rust extractor - * drops it too, so the compiled component genuinely has no styles from it; - * treating the class as unknown would hand it its siblings' stylesheets - * instead, which is the contamination this classification exists to avoid. - */ -function hasUnreadableKey(code: string, argsRange: [number, number]): boolean { - const [openParen, closeParen] = argsRange - const stack: Ctx[] = ['paren'] - let i = openParen + 1 - // Position within the decorator's own object literal. Keys sit at the - // start and after each comma; `:` hands over to the value. - let atKey = true - - while (i < closeParen) { - if (stack.length === 2 && stack[1] === 'brace') { - const ch = code[i] - if (WS_RE.test(ch)) { - i++ - continue - } - const afterComment = skipComment(code, i, closeParen) - if (afterComment !== -1) { - i = afterComment - continue - } - if (ch === ',') { - atKey = true - i++ - continue - } - if (ch === ':') { - atKey = false - i++ - continue - } - if (atKey) { - // `[` here opens a computed key, not an array value: a value only - // follows a `:`, which would have cleared `atKey`. - if (ch === '[') return true - if (ch === "'" || ch === '"') { - const close = findClosingDelim(code, i) - if (close === -1 || close >= closeParen) return true - if (decodeEscapes(code.slice(i + 1, close)) === null) return true - atKey = false - i = close + 1 - continue - } - const id = readIdentifierKey(code, i, closeParen) - if (id !== null) { - if (id.name === null) return true - i = id.end - continue - } - } - } - i = advanceOneToken(code, i, stack, closeParen) - } - return false -}