diff --git a/napi/angular-compiler/e2e/app/src/app/app.component.ts b/napi/angular-compiler/e2e/app/src/app/app.component.ts index 17d0c89b4..ee2c28635 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.component.ts +++ b/napi/angular-compiler/e2e/app/src/app/app.component.ts @@ -1,6 +1,7 @@ import { Component, signal } from '@angular/core' import { Card } from './card.component' +import { DuoFirst, DuoSecond } from './duo.component' import { InlineCard } from './inline-card.component' import { UTIL_VALUE } from './util' @@ -8,7 +9,7 @@ import { UTIL_VALUE } from './util' selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', - imports: [Card, InlineCard], + imports: [Card, InlineCard, DuoFirst, DuoSecond], }) export class App { protected readonly title = signal('E2E_TITLE') diff --git a/napi/angular-compiler/e2e/app/src/app/app.html b/napi/angular-compiler/e2e/app/src/app/app.html index 3a05a5126..3e9d268ca 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.html +++ b/napi/angular-compiler/e2e/app/src/app/app.html @@ -4,4 +4,6 @@

{{ title() }}

{{ utilValue }}

+ + diff --git a/napi/angular-compiler/e2e/app/src/app/duo.component.ts b/napi/angular-compiler/e2e/app/src/app/duo.component.ts new file mode 100644 index 000000000..44b113e3d --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/duo.component.ts @@ -0,0 +1,57 @@ +import { Component } from '@angular/core' + +// Two components in one file. Exercises the per-component HMR pipeline: +// each `@Component` class must get its own template/style cache and HMR +// dispatch slot, addressed by `filePath@ClassName`. + +@Component({ + selector: 'app-duo-first', + template: ` +
+

DUO_FIRST_TITLE

+

{{ message }}

+
+ `, + styles: [ + ` + :host { + display: block; + } + .duo.first { + --duo-first-color: tomato; + color: var(--duo-first-color); + padding: 0.5rem; + border: 1px solid currentColor; + } + `, + ], +}) +export class DuoFirst { + protected readonly message = 'first-component-in-multi-component-file' +} + +@Component({ + selector: 'app-duo-second', + template: ` +
+

DUO_SECOND_TITLE

+

{{ message }}

+
+ `, + styles: [ + ` + :host { + display: block; + } + .duo.second { + --duo-second-color: steelblue; + color: var(--duo-second-color); + padding: 0.5rem; + border: 1px dashed currentColor; + } + `, + ], +}) +export class DuoSecond { + protected readonly message = 'second-component-in-multi-component-file' +} diff --git a/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts b/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts new file mode 100644 index 000000000..15a5f6a0f --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-multi-component.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * Two `@Component` classes declared in the same `.ts` file. Each must: + * - render correctly on initial load, + * - 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). + */ +test.describe('Multi-component file HMR', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('both components render on initial load', async ({ page }) => { + await expect(page.locator('app-duo-first h3')).toContainText('DUO_FIRST_TITLE') + await expect(page.locator('app-duo-second h3')).toContainText('DUO_SECOND_TITLE') + }) + + test('inline template change in the FIRST component triggers HMR (no reload)', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('app-duo-first h3')).toContainText('DUO_FIRST_TITLE') + + await fileModifier.modifyFile('duo.component.ts', (content) => + content.replace('DUO_FIRST_TITLE', 'DUO_FIRST_HMR'), + ) + await waitForHmr() + + await expect(page.locator('app-duo-first h3')).toContainText('DUO_FIRST_HMR') + // Sibling untouched, no full reload. + await expect(page.locator('app-duo-second h3')).toContainText('DUO_SECOND_TITLE') + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) + + test('inline template change in the SECOND component triggers HMR (no reload)', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('app-duo-second h3')).toContainText('DUO_SECOND_TITLE') + + await fileModifier.modifyFile('duo.component.ts', (content) => + content.replace('DUO_SECOND_TITLE', 'DUO_SECOND_HMR'), + ) + await waitForHmr() + + await expect(page.locator('app-duo-second h3')).toContainText('DUO_SECOND_HMR') + await expect(page.locator('app-duo-first h3')).toContainText('DUO_FIRST_TITLE') + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) + + test('inline styles change in the SECOND component triggers HMR (no reload)', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + const before = await page + .locator('app-duo-second .duo.second') + .evaluate((el) => getComputedStyle(el).color) + expect(before).toMatch(/rgb\(/) + + await fileModifier.modifyFile('duo.component.ts', (content) => + content.replace('--duo-second-color: steelblue', '--duo-second-color: green'), + ) + await waitForHmr() + + // Color should have changed for the SECOND component; sentinel proves no reload. + const after = await page + .locator('app-duo-second .duo.second') + .evaluate((el) => getComputedStyle(el).color) + expect(after).not.toBe(before) + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) + + test('a non-template/styles edit in a multi-component file triggers full reload', async ({ + page, + fileModifier, + hmrDetector, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await fileModifier.modifyFile('duo.component.ts', (content) => + content.replace( + "first-component-in-multi-component-file'", + "first-component-in-multi-component-file-MODIFIED'", + ), + ) + await page.waitForEvent('load', { timeout: 15000 }) + await page.waitForLoadState('networkidle') + // Sentinel destroyed by full reload. + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(false) + await expect(page.locator('app-duo-first p')).toContainText( + 'first-component-in-multi-component-file-MODIFIED', + ) + }) +}) diff --git a/napi/angular-compiler/test/decorator-fields.test.ts b/napi/angular-compiler/test/decorator-fields.test.ts new file mode 100644 index 000000000..1816a57f5 --- /dev/null +++ b/napi/angular-compiler/test/decorator-fields.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it } from 'vitest' + +import { + emptyDelimitedRange, + locateComponentDecorators, + locateStylesFieldFor, + locateTemplateStringFor, +} from '../vite-plugin/utils/decorator-fields.js' + +describe('decorator-fields utils', () => { + describe('emptyDelimitedRange', () => { + it('empties the body of a styles array but keeps the brackets', () => { + const src = `before styles: ['x', 'y'] after` + const open = src.indexOf('[') + const close = src.indexOf(']') + expect(emptyDelimitedRange(src, [open, close])).toBe('before styles: [] after') + }) + + it('empties the body of a single-quoted template', () => { + const src = `before template: '

' after` + const open = src.indexOf("'") + const close = src.lastIndexOf("'") + expect(emptyDelimitedRange(src, [open, close])).toBe(`before template: '' after`) + }) + + it('empties the body of a double-quoted template', () => { + const src = `before template: "

" after` + const open = src.indexOf('"') + const close = src.lastIndexOf('"') + expect(emptyDelimitedRange(src, [open, close])).toBe(`before template: "" after`) + }) + + it('empties the body of a template literal', () => { + const src = 'before template: `

` after' + const open = src.indexOf('`') + const close = src.lastIndexOf('`') + expect(emptyDelimitedRange(src, [open, close])).toBe('before template: `` after') + }) + + it('is a no-op when the range already wraps an empty body', () => { + const src = `x [] y` + expect(emptyDelimitedRange(src, [2, 3])).toBe(src) + }) + }) + + describe('locateComponentDecorators', () => { + it('returns [] when the source has no @Component decorator', () => { + expect(locateComponentDecorators(`export class Foo {}`)).toEqual([]) + }) + + it('returns [] when @Component is present but no class follows', () => { + // No class declared at all — we can't pair the decorator to a name. + const src = `@Component({ selector: 'x' })` + expect(locateComponentDecorators(src)).toEqual([]) + }) + + it('returns a single entry for a single-component file', () => { + const src = `@Component({ selector: 'x' })\nexport class FooComponent {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('FooComponent') + // argsRange covers `(...)` inclusive + expect(src[out[0].argsRange[0]]).toBe('(') + expect(src[out[0].argsRange[1]]).toBe(')') + }) + + it('returns one entry per @Component in a multi-component file', () => { + const src = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-first', template: '

First
' }) + export class FirstComponent {} + @Component({ selector: 'app-second', template: 'Second' }) + export class SecondComponent {} + ` + const out = locateComponentDecorators(src) + expect(out.map((d) => d.className)).toEqual(['FirstComponent', 'SecondComponent']) + // each argsRange must enclose its own args (the inner JSON literal) + expect(src.slice(...out[0].argsRange)).toContain('app-first') + expect(src.slice(...out[1].argsRange)).toContain('app-second') + }) + + it('handles plain `class Foo` (no `export`)', () => { + const src = `@Component({ template: '' })\nclass Foo {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles `export default class Foo`', () => { + const src = `@Component({ template: '' })\nexport default class Foo {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles `export abstract class Foo`', () => { + const src = `@Component({ template: '' })\nexport abstract class Foo {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles extra decorators between @Component(...) and class', () => { + const src = `@Component({ template: '' })\n@Inject() @Other()\nexport class Foo {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles a class with generics, extends, and implements', () => { + const src = `@Component({ template: '' })\nexport class Foo extends Base implements Baz {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles class names that start with `$` or `_`', () => { + const src = `@Component({ template: '' })\nclass $Foo {}\n@Component({ template: '' })\nclass _Bar {}` + const out = locateComponentDecorators(src) + expect(out.map((d) => d.className)).toEqual(['$Foo', '_Bar']) + }) + + it('skips an anonymous default-exported component (no identifier to pair)', () => { + // `export default class { ... }` has no name. The decorator can't be + // matched to a className → entry is skipped (HMR can't address it). + const src = `@Component({ template: '' })\nexport default class {}` + expect(locateComponentDecorators(src)).toEqual([]) + }) + + it('does not pair an @Component with a class that belongs to a later decorator', () => { + // The first @Component has no class before the next @Component (which has + // its own class). The first entry should be SKIPPED, not paired with Bar. + // (Note: comments aren't parsed away, so this fixture deliberately omits + // the word `class` from the dangling region.) + const src = ` + @Component({ template: '' }) + @Component({ template: '' }) + class Bar {} + ` + const out = locateComponentDecorators(src) + expect(out.map((d) => d.className)).toEqual(['Bar']) + }) + + it('does not pair when the next class follows another @Component', () => { + // Same idea: the first @Component is dangling. + const src = ` + @Component({ template: '' }) + @Component({ template: '' }) + class A {} + @Component({ template: '' }) + class B {} + ` + const out = locateComponentDecorators(src) + expect(out.map((d) => d.className)).toEqual(['A', 'B']) + }) + }) + + describe('locateStylesFieldFor', () => { + const multi = ` + @Component({ selector: 'a', styles: ['.first {}'] }) + export class FirstComponent {} + @Component({ selector: 'b', styles: ['.second {}'] }) + export class SecondComponent {} + ` + + it('returns null when className matches no decorator', () => { + expect(locateStylesFieldFor(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() + }) + + 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 {}']`) + }) + + 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 {}']`) + }) + + it('supports the bare-string styles form per component', () => { + const src = ` + @Component({ styles: '.first {}' }) + export class FirstComponent {} + @Component({ styles: '.second {}' }) + export class SecondComponent {} + ` + const range = locateStylesFieldFor(src, 'SecondComponent')! + expect(src.slice(range[0], range[1] + 1)).toBe(`'.second {}'`) + }) + + // The next four guard against false-matches: a `styles:` key occurring + // inside another field's string/template literal must not be picked up. + 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']`) + }) + + 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() + }) + + it("ignores `styles:` inside a `${...}` interpolation's nested object literal", () => { + // `${ ... { styles: [...] } ... }` inside a template literal must not + // 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']`) + }) + + 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']`) + }) + }) + + describe('locateTemplateStringFor', () => { + const multi = ` + @Component({ selector: 'a', template: '' }) + export class FirstComponent {} + @Component({ selector: 'b', template: '' }) + export class SecondComponent {} + ` + + it('returns null when className matches no decorator', () => { + expect(locateTemplateStringFor(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() + }) + + it('returns the FirstComponent template range when asked for FirstComponent', () => { + const range = locateTemplateStringFor(multi, 'FirstComponent')! + expect(multi.slice(range[0], range[1] + 1)).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(`''`) + }) + + it("ignores `template:` text appearing inside another field's string literal", () => { + // The `styles` array contains a string with literal `template:` text; + // 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(`''`) + }) + }) + + // ----------------------------------------------------------------- + // 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({ + // I'm setting the styles below + styles: ['real'] +}) +class Foo {}` + const range = locateStylesFieldFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + }) + + it('does not get stuck on apostrophes inside a block comment', () => { + const src = `@Component({ + /* It's important: don't use these */ + styles: ['real'] +}) +class Foo {}` + const range = locateStylesFieldFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + }) + + it('ignores `styles:` inside a line comment', () => { + const src = `@Component({ + // styles: ['fake'], + styles: ['real'] +}) +class Foo {}` + const range = locateStylesFieldFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + }) + + it('ignores `styles:` inside a block comment', () => { + const src = `@Component({ + /* styles: ['fake'] */ + styles: ['real'] +}) +class Foo {}` + const range = locateStylesFieldFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + }) + + it('ignores `template:` inside a block comment', () => { + const src = `@Component({ + /* template: '' */ + template: '' +}) +class Foo {}` + const range = locateTemplateStringFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`''`) + }) + + it('returns null when the only `styles:` is inside a comment', () => { + const src = `@Component({ + // styles: ['fake'] + selector: 'app-foo' +}) +class Foo {}` + expect(locateStylesFieldFor(src, 'Foo')).toBeNull() + }) + + it('handles a block comment spanning multiple lines', () => { + const src = `@Component({ + /* + * styles: ['fake-line-1'] + * styles: ['fake-line-2'] + */ + styles: ['real'] +}) +class Foo {}` + const range = locateStylesFieldFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).toBe(`['real']`) + }) + + it('handles a comment between @Component(...) and the class declaration', () => { + const src = `@Component({ styles: ['x'] }) +// I'm decorating this class +export class Foo {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('does NOT treat `//` inside a string as a comment', () => { + // `'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']`) + }) + + 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']`) + }) + }) + + // ----------------------------------------------------------------- + // Regression coverage for things that *could* look like a decorator + // or field but must not be picked up: text inside other strings, in + // other decorators, in class member bodies, etc. + // ----------------------------------------------------------------- + describe('robust against decoy tokens elsewhere in the source', () => { + it('ignores `@Component(...)` example inside a JSDoc block before the real decorator', () => { + const src = `/** + * Usage example: + * @Component({ template: 'fake' }) + * class Example {} + */ +@Component({ template: '' }) +export class Foo {}` + const range = locateTemplateStringFor(src, 'Foo')! + expect(src.slice(range[0], range[1] + 1)).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(`''`) + }) + + 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(`''`) + }) + + 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']`) + }) + + 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']`) + }) + + 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']`) + }) + + 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']`) + }) + + 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']`) + }) + + it('ignores a class member method named `Component`', () => { + const src = `@Component({ template: '' }) +class Foo { + Component(x: number) { return x; } +}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Foo') + }) + + it('handles a helper function whose body contains an "@Component" string between two real components', () => { + const src = `@Component({ template: '' }) +class First {} +const helper = () => '@Component({...})' +@Component({ template: '' }) +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(`''`) + }) + + 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}`') + }) + + it('coexists with other class-level decorators like @SignalComponent', () => { + const src = `@SignalComponent({ template: 'sig' }) +@Component({ template: '' }) +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(`''`) + }) + }) + + describe('Unicode class identifiers', () => { + it('captures a class name containing non-ASCII letters', () => { + const src = `@Component({ styles: ['x'] })\nclass Café {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('Café') + }) + + it('captures CJK class names', () => { + const src = `@Component({ styles: ['x'] })\nclass 组件 {}` + const out = locateComponentDecorators(src) + expect(out).toHaveLength(1) + expect(out[0].className).toBe('组件') + }) + }) +}) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 893a8922e..fc7048aa3 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -265,6 +265,261 @@ describe('pendingHmrUpdates race condition', () => { expect(thirdBody, 'expected pending entry to be consumed after successful HMR').toBe('') }) + it('dispatches an HMR event per component when a multi-component .ts file changes (inline template edit)', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + // Two @Component classes in one file with inline templates. + const multiComponentPath = join(appDir, 'multi.component.ts') + const originalSource = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-first', template: '
First
' }) + export class FirstComponent {} + @Component({ selector: 'app-second', template: '
Second
' }) + export class SecondComponent {} + ` + writeFileSync(multiComponentPath, originalSource) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {} } as any, + originalSource, + multiComponentPath, + ) + + // Edit only FirstComponent's template — stripped form should match the + // cached stripped form, so the HMR (not full-reload) branch fires. + const editedSource = originalSource.replace('
First
', '
First Edited
') + writeFileSync(multiComponentPath, editedSource) + + const ctx = createMockHmrContext(multiComponentPath, [{ id: multiComponentPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + // Both components in the file should receive an HMR event (we + // conservatively dispatch all components when the strip-equality check + // passes — per-component diffing is a future optimization). + const updateMsgs = mockServer._wsMessages.filter( + (m: any) => m.event === 'angular:component-update', + ) + const componentIds = updateMsgs.map((m: any) => decodeURIComponent(m.data.id)) + expect(componentIds).toContain(`${multiComponentPath}@FirstComponent`) + expect(componentIds).toContain(`${multiComponentPath}@SecondComponent`) + + // The plugin must NOT have dispatched a full reload. + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + expect(fullReload, 'expected no full-reload for inline template change').toBeUndefined() + }) + + it('serves the correct per-component HMR module via the @ng/component endpoint', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const multiComponentPath = join(appDir, 'multi-endpoint.component.ts') + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-a', template: '

A

' }) + export class AComponent {} + @Component({ selector: 'app-b', template: '

B

' }) + export class BComponent {} + ` + writeFileSync(multiComponentPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {} } as any, + source, + multiComponentPath, + ) + + // Trigger a hot update so both components are queued in pendingHmrUpdates. + writeFileSync(multiComponentPath, source.replace('

A

', '

A!

')) + const ctx = createMockHmrContext(multiComponentPath, [{ id: multiComponentPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + expect(middleware, 'expected middleware to be registered').toBeDefined() + + // Request the HMR module for BOTH components — each should resolve with + // a non-empty payload that mentions its own className. + const aBody = await invokeAngularMiddleware(middleware, `${multiComponentPath}@AComponent`) + const bBody = await invokeAngularMiddleware(middleware, `${multiComponentPath}@BComponent`) + + expect(aBody, 'expected non-empty HMR body for AComponent').not.toBe('') + expect(bBody, 'expected non-empty HMR body for BComponent').not.toBe('') + expect(aBody).toContain('AComponent') + expect(bBody).toContain('BComponent') + }) + + it("dispatches HMR for both components when only one component's inline styles change", async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const multiStylesPath = join(appDir, 'multi-styles.component.ts') + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-x', template: '', styles: ['.x { color: red }'] }) + export class XComponent {} + @Component({ selector: 'app-y', template: '', styles: ['.y { color: blue }'] }) + export class YComponent {} + ` + writeFileSync(multiStylesPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call({ error() {}, warn() {} } as any, source, multiStylesPath) + + // Edit only YComponent's styles. Stripping wipes BOTH components' styles + // (and templates), so old and new stripped forms must still match. + writeFileSync(multiStylesPath, source.replace('.y { color: blue }', '.y { color: green }')) + + const ctx = createMockHmrContext(multiStylesPath, [{ id: multiStylesPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const componentIds = mockServer._wsMessages + .filter((m: any) => m.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(componentIds).toContain(`${multiStylesPath}@XComponent`) + expect(componentIds).toContain(`${multiStylesPath}@YComponent`) + + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + expect(fullReload, 'expected no full-reload for inline styles change').toBeUndefined() + }) + + it('external templateUrl HMR fans out to every component in a multi-component file', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + // Two components in one file, each with its own templateUrl. + const externalDir = appDir + const firstHtmlPath = join(externalDir, 'first.component.html') + const secondHtmlPath = join(externalDir, 'second.component.html') + const multiUrlPath = join(externalDir, 'multi-url.component.ts') + writeFileSync(firstHtmlPath, '

First

') + writeFileSync(secondHtmlPath, '

Second

') + + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-first', templateUrl: './first.component.html' }) + export class FirstComponent {} + @Component({ selector: 'app-second', templateUrl: './second.component.html' }) + export class SecondComponent {} + ` + writeFileSync(multiUrlPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call({ error() {}, warn() {} } as any, source, multiUrlPath) + + // Edit just first.component.html. resourceToComponent maps it to + // multi-url.component.ts; dispatchAllComponentsInFile must fan out to + // BOTH FirstComponent and SecondComponent (over-dispatch is safe — + // Angular's runtime no-ops replaceMetadata when nothing changed). + writeFileSync(firstHtmlPath, '

First edited

') + const ctx = createMockHmrContext( + normalizePath(firstHtmlPath), + [{ id: normalizePath(firstHtmlPath) }], + mockServer, + ) + await callHandleHotUpdate(plugin, ctx) + + const componentIds = mockServer._wsMessages + .filter((m: any) => m.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(componentIds).toContain(`${multiUrlPath}@FirstComponent`) + expect(componentIds).toContain(`${multiUrlPath}@SecondComponent`) + }) + + it('consumes a stale pending slot when the requested className is no longer in the file', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const stalePath = join(appDir, 'stale.component.ts') + const originalSource = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-keep', template: '' }) + export class KeepComponent {} + @Component({ selector: 'app-drop', template: '' }) + export class DropComponent {} + ` + writeFileSync(stalePath, originalSource) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call({ error() {}, warn() {} } as any, originalSource, stalePath) + + // Trigger an HMR-eligible edit so a pending entry is queued for both + // components (including DropComponent). + writeFileSync(stalePath, originalSource.replace('', '')) + const ctx = createMockHmrContext(stalePath, [{ id: stalePath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + // Now re-transform with DropComponent removed. The prune logic must drop + // the cached pending entry for DropComponent so a later request for it + // doesn't loop. + const reducedSource = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-keep', template: '' }) + export class KeepComponent {} + ` + writeFileSync(stalePath, reducedSource) + await plugin.transform.handler.call({ error() {}, warn() {} } as any, reducedSource, stalePath) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + + // A request for the now-gone DropComponent must return '' and NOT trigger + // a phantom HMR module / error / invalidate event. + const dropBody = await invokeAngularMiddleware(middleware, `${stalePath}@DropComponent`) + expect(dropBody).toBe('') + // A second request must also return '' (pending slot consumed first time). + const dropBody2 = await invokeAngularMiddleware(middleware, `${stalePath}@DropComponent`) + expect(dropBody2).toBe('') + }) + + it('triggers full reload when a multi-component .ts changes outside template/styles', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const multiReloadPath = join(appDir, 'multi-reload.component.ts') + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-r1', template: '' }) + export class R1Component { value = 1; } + @Component({ selector: 'app-r2', template: '' }) + export class R2Component {} + ` + writeFileSync(multiReloadPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call({ error() {}, warn() {} } as any, source, multiReloadPath) + + // Change a class member, NOT the template/styles. The stripped form will + // differ from the cached one → full reload, no HMR. + writeFileSync(multiReloadPath, source.replace('value = 1', 'value = 2')) + + const ctx = createMockHmrContext(multiReloadPath, [{ id: multiReloadPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const componentUpdates = mockServer._wsMessages.filter( + (m: any) => m.event === 'angular:component-update', + ) + expect( + componentUpdates, + 'expected no component-update events for non-template change', + ).toHaveLength(0) + + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + expect(fullReload, 'expected a full-reload event').toBeDefined() + }) + it('consumes pending entry and dispatches angular:invalidate on compile error', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 3ccdca735..5bb2ce6b5 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -35,6 +35,14 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' import { jitPlugin } from './angular-jit-plugin.js' import { angularLinkerPlugin } from './angular-linker-plugin.js' import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js' +import { + emptyDelimitedRange, + locateComponentDecorators, + locateStylesFieldFor, + locateStylesInArgs, + locateTemplateInArgs, + locateTemplateStringFor, +} from './utils/decorator-fields.js' /** * Plugin options for the Angular Vite plugin. @@ -182,26 +190,29 @@ export function angular(options: PluginOptions = {}): Plugin[] { let inlineBuild: InlineBuildMinifyOptions | undefined let outputMinify: unknown - // Track component IDs for HMR - const componentIds = new Map() + // For each component .ts file, the set of component class names declared + // inside it. A file can legally have multiple @Component classes — Angular + // emits per-component HMR updates and we mirror that here. + const componentsByFile = new Map>() - // Reverse mapping: resource file path → component file path + // Reverse mapping: resource file path → component file path. Multi-component + // files almost always use inline templates, so a single owner per resource is + // sufficient in practice; if a templateUrl/styleUrl is shared across multiple + // components in the same file, only one will receive the HMR event. const resourceToComponent = new Map() // Cache for resolved resources const resourceCache = new Map() - // Component files queued for HMR delivery. Populated by `handleHotUpdate` - // when an external resource or inline template/style change is detected, - // and consumed by the `@ng/component` HTTP endpoint, which reads it to - // decide whether to serve the update module or an empty response. + // Component IDs (`filePath@ClassName`) queued for HMR delivery. Populated by + // `handleHotUpdate` when an external resource or inline template/style change + // is detected, and consumed by the `@ng/component` HTTP endpoint, which reads + // it to decide whether to serve the update module or an empty response. const pendingHmrUpdates = new Set() - // Cache inline template content per .ts file for detecting template-only changes + // Per-component caches keyed by `filePath@ClassName`. A multi-component file + // contributes one entry per component to each map. const inlineTemplateCache = new Map() - - // Cache the inline styles array per .ts file. Used by the HMR endpoint to - // serve fresh inline styles to ɵɵreplaceMetadata when the styles change. const inlineStylesCache = new Map() // Cache the source of each component .ts file with its `template:` and @@ -404,6 +415,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } const fileId = decodedComponentId.slice(0, atIndex) + const className = decodedComponentId.slice(atIndex + 1) const resolvedId = resolve(process.cwd(), fileId) // Only return an HMR update module if `handleHotUpdate` queued @@ -412,7 +424,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { // ɵɵreplaceMetadata from being called unnecessarily during // initial load, which would re-create views and cause errors // with @Required() decorators. - if (!pendingHmrUpdates.has(fileId)) { + if (!pendingHmrUpdates.has(decodedComponentId)) { + res.setHeader('Content-Type', 'text/javascript') + res.setHeader('Cache-Control', 'no-cache') + res.end('') + return + } + + // If the requested className isn't (or is no longer) in the + // file, the pending slot is stale and would otherwise stick + // around indefinitely because the transient-empty preservation + // logic below assumes a future save will resolve it. Consume + // and return empty. + if (!componentsByFile.get(resolvedId)?.has(className)) { + pendingHmrUpdates.delete(decodedComponentId) res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end('') @@ -433,12 +458,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { templateContent = options.templateTransform(templateContent, templatePath) } } else { - templateContent = extractInlineTemplate(source) + templateContent = extractInlineTemplate(source, className) } if (templateContent) { - const className = componentIds.get(resolvedId) ?? 'Component' - // 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 @@ -468,7 +491,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } else { // No external styleUrls — fall back to inline `styles: […]`. - const inlineStyles = extractInlineStyles(source) + const inlineStyles = extractInlineStyles(source, className) if (inlineStyles !== null && inlineStyles.length > 0) { styles = inlineStyles } @@ -484,7 +507,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // transiently empty (truncate phase of an atomic write on // Linux), the next inotify event's request would find no // pending entry and deliver no HMR. - pendingHmrUpdates.delete(fileId) + pendingHmrUpdates.delete(decodedComponentId) res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end(result.hmrModule) @@ -497,7 +520,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Consume the pending slot on error to prevent repeated failed // compilations on every subsequent browser request. - pendingHmrUpdates.delete(fileId) + pendingHmrUpdates.delete(decodedComponentId) // Send angular:invalidate event to trigger graceful full reload // This matches Angular's HMR error fallback pattern @@ -607,9 +630,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { this.warn(warning.message) } - // Track component IDs for HMR + // Track component IDs for HMR — one entry per @Component class. if (pluginOptions.liveReload) { - // templateUpdates is a plain object (NAPI HashMap → JS object) + // templateUpdates is keyed by `filePath@ClassName` (NAPI HashMap → JS object). const templateUpdateKeys = Object.keys(result.templateUpdates) debugTransform( 'transform %s templateUpdates=%O deps=%O', @@ -617,24 +640,52 @@ export function angular(options: PluginOptions = {}): Plugin[] { templateUpdateKeys, dependencies, ) + const classNamesInFile = new Set() for (const componentId of templateUpdateKeys) { - const [, className] = componentId.split('@') - componentIds.set(actualId, className) - debugHmr('registered: %s -> %s', actualId, className) + const atIdx = componentId.indexOf('@') + 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. + 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) + } } - // Cache inline template / styles for detecting template-only or - // styles-only changes in handleHotUpdate, and the metadata-stripped - // source for cheaply diffing whether anything else changed. - const inlineTemplate = extractInlineTemplate(code) - if (inlineTemplate !== null) { - inlineTemplateCache.set(actualId, inlineTemplate) + componentsByFile.set(actualId, classNamesInFile) + for (const className of classNamesInFile) { + debugHmr('registered: %s -> %s', actualId, className) } - const inlineStyles = extractInlineStyles(code) - if (inlineStyles !== null) { - inlineStylesCache.set(actualId, inlineStyles) - } else { - inlineStylesCache.delete(actualId) + + // 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) + } } componentMetadataCache.set(actualId, stripComponentMetadata(code)) } @@ -652,21 +703,25 @@ export function angular(options: PluginOptions = {}): Plugin[] { const normalizedFile = normalizePath(ctx.file) - // Helper: dispatch a component HMR update for the owning component - // (used by both external resource and inline template/style branches). - // Returns true if dispatched. - const dispatchComponentUpdate = (componentFile: string): boolean => { - if (!componentIds.has(componentFile)) return false + // Helper: dispatch an HMR update for a specific component (identified + // by its componentFile + className). Used by both external resource + // and inline template/style branches. Returns true if dispatched. + const dispatchComponentUpdate = (componentFile: string, className: string): boolean => { + const classNames = componentsByFile.get(componentFile) + if (!classNames || !classNames.has(className)) return false + + const componentId = `${componentFile}@${className}` // The HMR HTTP endpoint reads this set to decide whether to serve // the update module or an empty response. - pendingHmrUpdates.add(componentFile) + pendingHmrUpdates.add(componentId) // Invalidate the component's module so the next request reads fresh - // template/style content. + // template/style content. Module is per-file; safe to invalidate + // once even if multiple components share it (subsequent dispatch + // calls for siblings will no-op the invalidation). const mod = ctx.server.moduleGraph.getModuleById(componentFile) if (mod) ctx.server.moduleGraph.invalidateModule(mod) - const componentId = `${componentFile}@${componentIds.get(componentFile)}` const encodedId = encodeURIComponent(componentId) debugHmr('sending angular:component-update id=%s', encodedId) ctx.server.ws.send({ @@ -677,6 +732,22 @@ export function angular(options: PluginOptions = {}): Plugin[] { return true } + // Dispatch an HMR event for every component in the given file. Used + // when a whole-file diff (or external resource) tells us the change + // is contained within template/styles but we can't cheaply attribute + // it to a specific component. Angular's runtime no-ops + // ɵɵreplaceMetadata when the metadata didn't actually change, so + // over-dispatching is safe. + const dispatchAllComponentsInFile = (componentFile: string): boolean => { + const classNames = componentsByFile.get(componentFile) + if (!classNames || classNames.size === 0) return false + let dispatched = false + for (const className of classNames) { + if (dispatchComponentUpdate(componentFile, className)) dispatched = true + } + return dispatched + } + // ------------------------------------------------------------ // Branch 1: external component resource (templateUrl / styleUrl) // ------------------------------------------------------------ @@ -690,7 +761,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (resourceToComponent.has(normalizedFile)) { const componentFile = resourceToComponent.get(normalizedFile)! resourceCache.delete(normalizedFile) - if (dispatchComponentUpdate(componentFile)) { + // resourceToComponent only tracks one owner per resource; if a + // templateUrl/styleUrl is shared across multiple components in + // the same file, only the registered owner receives HMR. + if (dispatchAllComponentsInFile(componentFile)) { debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) return [] } @@ -702,7 +776,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // ------------------------------------------------------------ // Branch 2: component .ts (has @Component decorator) // ------------------------------------------------------------ - // The transform pass populates componentIds for every component .ts. + // The transform pass populates componentsByFile for every component .ts. // A change here is either: // (a) only the inline `template:` and/or `styles:` fields changed // → HMR (no reload), matching Angular CLI's behavior. @@ -710,20 +784,32 @@ export function angular(options: PluginOptions = {}): Plugin[] { // → full reload, since Angular's runtime can't safely hot-swap // class definitions. const isTsFile = ANGULAR_TS_REGEX.test(ctx.file) - if (isTsFile && componentIds.has(ctx.file)) { - // If a pending update was already registered for this component - // (e.g. an external template change just invalidated the .ts module - // via the graph), the resource branch has it covered. - if (pendingHmrUpdates.has(ctx.file)) { + if (isTsFile && componentsByFile.has(ctx.file)) { + // If a pending update is already queued for ANY component in this + // file (e.g. an external template change just invalidated the .ts + // module via the graph), the resource branch has it covered. + const fileClassNames = componentsByFile.get(ctx.file)! + let alreadyPending = false + for (const className of fileClassNames) { + if (pendingHmrUpdates.has(`${ctx.file}@${className}`)) { + alreadyPending = true + break + } + } + if (alreadyPending) { debugHmr('component .ts: pending HMR already queued, skip') return [] } - // Strip-based check: if the source with `template:` and `styles:` - // decorator fields stripped is byte-identical to the cached stripped - // form, the diff is contained entirely in those fields and we can - // HMR. This covers inline-template-only, inline-style-only, and - // both-at-once changes uniformly. + // Strip-based check: if the source with EVERY @Component's + // `template:` and `styles:` fields stripped is byte-identical to + // the cached stripped form, the diff is contained entirely in + // those fields (for one or more components in the file) and we + // can HMR. This covers inline-template-only, inline-style-only, + // and both-at-once changes uniformly, including the multi-component + // case (a single edit to one component's template still satisfies + // the equality because the other components' stripped fields are + // identical before/after). const cachedStripped = componentMetadataCache.get(ctx.file) if (cachedStripped !== undefined) { let newContent: string @@ -735,16 +821,27 @@ 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) - const newTemplate = extractInlineTemplate(newContent) - if (newTemplate !== null) inlineTemplateCache.set(ctx.file, newTemplate) - const newStyles = extractInlineStyles(newContent) - if (newStyles !== null) { - inlineStylesCache.set(ctx.file, newStyles) - } else { - inlineStylesCache.delete(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) - dispatchComponentUpdate(ctx.file) + // Conservatively dispatch HMR for every component in the file — + // Angular's runtime no-ops if a component's metadata didn't + // actually change. Per-component diffing is an easy follow-up. + dispatchAllComponentsInFile(ctx.file) return [] } } @@ -843,35 +940,40 @@ export function angular(options: PluginOptions = {}): Plugin[] { } /** - * Extract inline template from @Component decorator. + * 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): string | null { - // Simple regex to extract inline template - const templateMatch = code.match(/template\s*:\s*`([^`]*)`/s) - if (templateMatch) { - return templateMatch[1] - } - - const templateQuoteMatch = code.match(/template\s*:\s*['"]([^'"]*)['"]/) - if (templateQuoteMatch) { - return templateQuoteMatch[1] - } - - return null +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 inline styles array from @Component decorator. + * Extract the inline styles from the `@Component({...})` decorator that + * decorates the class named `className`, as a positional array. * - * Handles `styles: [\`…\`]`, `styles: ['…']`, and `styles: ["…"]` and - * combinations thereof. Returns null if no `styles:` array is present. + * 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): string[] | null { - const arrMatch = code.match(/styles\s*:\s*\[([\s\S]*?)\]/) - if (!arrMatch) return null - const body = arrMatch[1] - // Match each string literal in the array body. Order matters for HMR - // delivery since styles are positional. +function extractInlineStyles(code: string, className: string): string[] | null { + const range = locateStylesFieldFor(code, className) + if (!range) return null + const opener = code[range[0]] + if (opener !== '[') { + // Bare string form — return the inner contents as a single element. + return [code.slice(range[0] + 1, range[1])] + } + // Array form — walk string literals inside the array body in order. + const body = code.slice(range[0] + 1, range[1]) const stringRe = /`([\s\S]*?)`|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"/g const styles: string[] = [] let m: RegExpExecArray | null @@ -882,22 +984,31 @@ function extractInlineStyles(code: string): string[] | null { } /** - * Replace the inline `template:` and `styles:` decorator fields with empty - * placeholders. Used to detect "only template and/or styles changed" — if - * the stripped form of the old and new source is byte-identical, the diff - * is contained within those fields and we can dispatch an HMR component - * update instead of a full reload. - * - * Note: each replace targets the FIRST occurrence only. This assumes one - * `@Component` decorator per file (Angular convention). Files with multiple - * components fall through to full-reload, which is the safe default. + * Empty the `template:` and `styles:` field values of *every* `@Component(...)` + * in the source, returning the result. Used to detect "only template/styles + * changed somewhere in the file" — if the stripped form of the old and new + * source is byte-identical, the diff is contained within those fields and we + * can dispatch HMR (one event per component in the file) instead of a full + * reload. */ function stripComponentMetadata(code: string): string { - return code - .replace(/template\s*:\s*`[\s\S]*?`/, 'template:``') - .replace(/template\s*:\s*'(?:\\.|[^'\\])*'/, "template:''") - .replace(/template\s*:\s*"(?:\\.|[^"\\])*"/, 'template:""') - .replace(/styles\s*:\s*\[[\s\S]*?\]/, 'styles:[]') + // Enumerate decorators ONCE (O(N) walk of source) and look up each one's + // template + styles range directly from its argsRange. Calling the + // className-based locators per decorator would re-enumerate inside each, + // giving O(N²). + // + // Splice from highest start → lowest so earlier offsets stay valid as we + // mutate the string from the end backwards. + const decorators = locateComponentDecorators(code) + const ranges: Array<[number, number]> = [] + for (const d of decorators) { + const tpl = locateTemplateInArgs(code, d.argsRange) + if (tpl) ranges.push(tpl) + const styles = locateStylesInArgs(code, d.argsRange) + if (styles) ranges.push(styles) + } + ranges.sort((a, b) => b[0] - a[0]) + return ranges.reduce((acc, range) => emptyDelimitedRange(acc, range), code) } export { angular as default } diff --git a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts new file mode 100644 index 000000000..0c59c9f61 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts @@ -0,0 +1,412 @@ +/** + * Helpers for locating inline `@Component` decorator fields in source text. + * + * 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 + * selectors (`[data-test="foo"]`), and template strings can contain escaped + * quotes or backticks. These helpers walk the source character by character, + * tracking string/template-literal boundaries (including `${…}` + * interpolations), JavaScript comments (`//` and `/* … *\/`), and the + * @Component object-literal nesting depth so delimiters inside literals or + * comments don't affect the search. + * + * Known limitations (not handled, fall through to safe defaults): + * - **Regex literals** inside `@Component(...)` args. The walker can't + * distinguish `/` as a division operator from `/` as a regex-literal + * opener without a real JS lexer. Regex literals inside @Component + * args don't appear in real Angular code, so this is accepted. + * - **Aliased decorator imports**: `@core.Component(...)` or + * `import { Component as C }` followed by `@C({...})`. Only the + * literal `@Component` form is recognized. + * - **Parenthesized decorator expressions** like `@(Component as any)(...)` + * — uncommon and not supported. + * - **Quoted (`{ 'styles': [...] }`) or computed (`{ ['styles']: [...] }`) + * property keys** for the `styles`/`template` field. Almost never used + * in Angular; locator returns null for such forms. + * - **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. + * - **Anonymous default-exported components** (`@Component({...}) export + * default class {}`) can't be HMR-addressed (no className) and are + * skipped by `locateComponentDecorators`. + */ + +// ----------------------------------------------------------------- +// Module-level constants & types +// ----------------------------------------------------------------- + +type Ctx = 'paren' | 'array' | 'brace' | 'sq' | 'dq' | 'tpl' + +const OPEN_TO_CTX: Record = { + '(': 'paren', + '[': 'array', + '{': 'brace', + "'": 'sq', + '"': 'dq', + '`': 'tpl', +} + +const CLOSER_TO_CTX: Record = { + ')': 'paren', + ']': 'array', + '}': 'brace', +} + +/** JavaScript whitespace, including line terminators and form feeds. */ +const WS_RE = /\s/ + +/** ASCII word characters (JS identifier continuation, minus Unicode). Used + * for word-boundary checks around the ASCII field keys `styles`/`template`. */ +const ASCII_WORD_RE = /[A-Za-z0-9_$]/ + +/** Unicode-aware JS identifier characters. Class names can be non-ASCII. */ +const IDENT_START_RE = /[\p{L}_$]/u +const IDENT_CONT_RE = /[\p{L}\p{N}_$]/u + +/** Opener chars accepted as the value of `styles:` — `string | string[]`. */ +const STYLES_OPENERS = '\'"`[' +/** Opener chars accepted as the value of `template:` — just string literals. */ +const TEMPLATE_OPENERS = '\'"`' + +/** + * If `code[i]` starts a `//` line comment or a `/* … *\/` block comment, + * return the index just past its end. Otherwise return -1. Caller must + * ensure it's in a code context (not inside a string or template literal). + * + * Pragmatic: this doesn't disambiguate `/` from a regex-literal opener, so + * regex literals inside `@Component(...)` args remain a known limitation. + * In practice they don't appear there. + */ +function skipComment(code: string, i: number, end: number): number { + if (code[i] !== '/') return -1 + const next = code[i + 1] + if (next === '/') { + let j = i + 2 + while (j < end && code[j] !== '\n') j++ + return j + } + if (next === '*') { + let j = i + 2 + while (j < end - 1 && !(code[j] === '*' && code[j + 1] === '/')) j++ + return Math.min(j + 2, end) + } + return -1 +} + +/** + * Process one structural token at `code[i]` against the parsing `stack` and + * return the next index to read. Mutates `stack` as a side-effect — pushes + * on opener / `${` / quote, pops on closer / matching quote / end of + * template literal. Inside string and template contexts, only escape + * sequences and closers are recognized. In code context, line and block + * comments are skipped. Mismatched closers are ignored (the stack is + * unchanged); the caller decides what to do based on its own stop + * condition. + * + * The `end` bound is used for comment-skipping so a block comment can't + * scan past the caller's intended boundary. + */ +function advanceOneToken(code: string, i: number, stack: Ctx[], end: number): number { + const top = stack[stack.length - 1] + const ch = code[i] + + if (top === 'sq' || top === 'dq') { + if (ch === '\\') return i + 2 + if ((top === 'sq' && ch === "'") || (top === 'dq' && ch === '"')) { + stack.pop() + } + return i + 1 + } + + if (top === 'tpl') { + if (ch === '\\') return i + 2 + if (ch === '`') { + stack.pop() + return i + 1 + } + if (ch === '$' && code[i + 1] === '{') { + stack.push('brace') + return i + 2 + } + return i + 1 + } + + // Code context. Try a comment first (opaque skip), then a delimiter + // (string opener, structural opener, or matching closer). + const afterComment = skipComment(code, i, end) + if (afterComment !== -1) return afterComment + + const opener = OPEN_TO_CTX[ch] + if (opener) { + stack.push(opener) + return i + 1 + } + + const closerCtx = CLOSER_TO_CTX[ch] + if (closerCtx && top === closerCtx) { + stack.pop() + } + return i + 1 +} + +/** + * Given the index of an opening delimiter (`(`, `[`, `{`, `'`, `"`, or `` ` ``), + * return the index of its matching closer. Honors string literals, escape + * sequences, and `${…}` interpolations inside template literals. Returns -1 + * if no balanced closer is found before EOF, or if the character at + * `openIdx` is not a known delimiter. + */ +export function findClosingDelim(code: string, openIdx: number): number { + const initial = OPEN_TO_CTX[code[openIdx]] + if (!initial) return -1 + + const stack: Ctx[] = [initial] + let i = openIdx + 1 + while (i < code.length && stack.length > 0) { + const next = advanceOneToken(code, i, stack, code.length) + if (stack.length === 0) { + // `advanceOneToken` consumed the closer; its index is `next - 1`. + return next - 1 + } + i = next + } + return -1 +} + +/** + * Replace everything between (but not including) the opener/closer at the + * given inclusive `[start, end]` range with nothing — leaving the original + * delimiters in place. Works uniformly for `[…]`, `'…'`, `"…"`, and `` `…` ``. + */ +export function emptyDelimitedRange(code: string, range: [number, number]): string { + const [start, end] = range + return code.slice(0, start + 1) + code.slice(end) +} + +/** + * Locate a top-level `: ` property inside a `@Component(...)` + * argument list, returning the inclusive `[start, end]` of the value's outer + * delimiters. "Top level" means a direct property of the @Component arg + * object — `{ : ... }` — not a nested object, not inside a string or + * template literal, not inside a `${...}` interpolation, and not inside a + * call argument that happens to be a nested object literal. + * + * Walks the args body character-by-character tracking string / template / + * paren / brace / array context, mirroring `findClosingDelim`. A field key + * is considered only when: + * - the parser is at the @Component's immediate object-literal depth + * (stack === ['paren', 'brace']); + * - the character before `field` is not a word character (word boundary); + * - `field` is followed by optional whitespace, then `:`, optional whitespace, + * then one of `openerChars`. + * + * Returns null if no qualifying field is found. + */ +function locateFieldInsideArgs( + code: string, + argsRange: [number, number], + field: string, + openerChars: string, +): [number, number] | null { + const [openParen, closeParen] = argsRange + const stack: Ctx[] = ['paren'] + let i = openParen + 1 + + while (i < closeParen) { + // Check for a field-key match BEFORE advancing. The match is only + // valid at the @Component's immediate object-literal depth + // (`['paren', 'brace']`) — anything deeper is a nested literal that + // isn't the component's metadata. + if (stack.length === 2 && stack[1] === 'brace' && isFieldKeyAt(code, i, field, closeParen)) { + let j = i + field.length + while (j < closeParen && WS_RE.test(code[j])) j++ + if (code[j] === ':') { + j++ + while (j < closeParen && WS_RE.test(code[j])) j++ + if (j < closeParen && openerChars.includes(code[j])) { + const end = findClosingDelim(code, j) + if (end !== -1 && end < closeParen) return [j, end] + } + } + } + i = advanceOneToken(code, i, stack, closeParen) + } + return null +} + +/** + * Whether `field` starts at `position` in `code` AND is bounded on both sides + * by non-identifier characters (so `template` doesn't match the start of + * `templateUrl`, and `someStyles:` doesn't match the end of `styles:`). + */ +function isFieldKeyAt(code: string, position: number, field: string, limit: number): boolean { + if (position + field.length > limit) return false + if (!code.startsWith(field, position)) return false + const prev = position > 0 ? code[position - 1] : '' + if (prev && ASCII_WORD_RE.test(prev)) return false + const next = code[position + field.length] + if (next !== undefined && ASCII_WORD_RE.test(next)) return false + return true +} + +/** + * One @Component decorator paired with the class it decorates. + */ +export interface ComponentDecorator { + /** Inclusive offsets of the outer `(` and `)` of `@Component(...)`. */ + argsRange: [number, number] + /** The class name declared after this decorator. */ + className: string +} + +/** + * Enumerate every `@Component(...)` decorator in `code`, pairing each with + * the class declared immediately after it. Decorators that don't pair to a + * class (dangling, malformed, anonymous) are skipped — the caller sees only + * well-formed component declarations. + * + * The class-name scan is bounded between this decorator's closing `)` and + * either the next `@Component\s*\(` or end of file. That bound prevents one + * decorator's scan from accidentally consuming a sibling's class identifier, + * and combined with the comment- and string-aware walkers in + * `findClosingDelim` and `findClassName` it correctly handles `@Component` + * occurrences inside comments, strings, and template literals. + * + * See the module-level docstring for a full list of known limitations. + */ +export function locateComponentDecorators(code: string): ComponentDecorator[] { + // Pass 1: find every `@Component(...)` and bound its args list. + type Found = { decoratorStart: number; openParen: number; closeParen: number } + const decoratorRe = /@Component\s*\(/g + const found: Found[] = [] + let m: RegExpExecArray | null + while ((m = decoratorRe.exec(code)) !== null) { + const openParen = m.index + m[0].length - 1 + const closeParen = findClosingDelim(code, openParen) + if (closeParen === -1) continue + found.push({ decoratorStart: m.index, openParen, closeParen }) + } + + // Pass 2: for each decorator, scan forward from its `)` to either the next + // decorator's `@` or EOF, looking for `class IDENT`. The bound stops one + // decorator's class-name scan from claiming a sibling's class. + const out: ComponentDecorator[] = [] + for (let i = 0; i < found.length; i++) { + const { openParen, closeParen } = found[i] + const scanEnd = i + 1 < found.length ? found[i + 1].decoratorStart : code.length + const className = findClassName(code, closeParen + 1, scanEnd) + if (className !== null) { + out.push({ argsRange: [openParen, closeParen], className }) + } + } + return out +} + +/** + * Find the first `class IDENT` whose `class` keyword appears in + * `[start, end)`. Returns the identifier (Unicode-aware), or null if no + * match. Skips line/block comments and string/template literals so a + * `// uses a base class Bar` comment or a `'class Baz'` string between the + * decorator and the class declaration can't fool the matcher. + * + * Modifiers like `export`, `default`, `abstract`, and additional decorators + * (`@Foo()`) between the @Component(...) and the class are skipped + * implicitly — they don't match the `class IDENT` pattern. + */ +function findClassName(code: string, start: number, end: number): string | null { + let i = start + while (i < end) { + // Skip comments (line and block) opaquely. + const afterComment = skipComment(code, i, end) + if (afterComment !== -1) { + i = afterComment + continue + } + + // Skip string / template literals opaquely — `class IDENT` text inside + // them is not a real class declaration. `findClosingDelim` handles + // escape sequences and `${...}` interpolations correctly. + const ch = code[i] + if (ch === "'" || ch === '"' || ch === '`') { + const close = findClosingDelim(code, i) + i = close === -1 ? end : close + 1 + continue + } + + // Try matching the `class` keyword at this position, gated by a word + // boundary before and after to avoid `subclass`, `classes`, etc. + if (code.startsWith('class', i)) { + const prev = i > 0 ? code[i - 1] : '' + const afterKw = code[i + 5] ?? '' + if ( + (prev === '' || !IDENT_CONT_RE.test(prev)) && + (afterKw === '' || !IDENT_CONT_RE.test(afterKw)) + ) { + let j = i + 5 + while (j < end && WS_RE.test(code[j])) j++ + if (j < end && IDENT_START_RE.test(code[j])) { + const idStart = j + j++ + while (j < end && IDENT_CONT_RE.test(code[j])) j++ + return code.slice(idStart, j) + } + } + } + + i++ + } + return 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. + * + * Returns the inclusive `[start, end]` of the value's outer delimiters, + * or null if the decorator has no `styles:` field. + */ +export function locateStylesInArgs( + code: string, + argsRange: [number, number], +): [number, number] | null { + return locateFieldInsideArgs(code, argsRange, 'styles', STYLES_OPENERS) +} + +/** + * 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. + */ +export function locateTemplateInArgs( + code: string, + argsRange: [number, number], +): [number, number] | null { + 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 +}