From 78940944c4212443e81debd0051433dfee61ef19 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 25 Aug 2026 14:33:59 +0800 Subject: [PATCH 1/3] fix(vite): resolve HMR resources from the extractor, not a text scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@ng/component` endpoint read a class's own `templateUrl` / `template` / `styleUrl(s)` / `styles` by scanning decorator TEXT. The Rust extractor folds same-file constants and interpolates template literals; the scan cannot. Every shape it could not read fell back to the FILE-LEVEL union, which in a multi-component file served a class its siblings' template and stylesheets. const DIR = './themes' @Component({ styleUrls: [`${DIR}/a.css`, SHARED_STYLE] }) The endpoint now asks the extractor. `extract_component_metadata_sync` was already written and correct but carried no `#[napi]`, so it was not on the JS surface; adding the attribute is the whole Rust change. That answer is definitive, because it is the SAME one the compiler uses. `transform.rs:2557` and the extractor both call `extract_component_metadata` with the same `collect_string_consts` table. The transform's only extra step, `resolve_styles`, turns URLs into content and never re-derives the URL list: compiled styles = metadata.styles ++ concat(content(u) for u in style_urls) └── inline, first ──┘ Eight fixtures were compared against the real compile and all matched, including the one that looks like a partial read: `[OK, IMPORTED, './lit.css']` resolves to two entries in BOTH. The dropped import is genuinely not a stylesheet this component gets, so serving two is exact, not partial. A class MISSING from the metadata is equally definitive — measured, the compiler skips it too, leaving the decorator intact with no `ɵcmp`. So both fallbacks are deleted rather than narrowed. This retires the `unreadable` state, which existed only because the text scan disagreed with Rust. What does NOT change is `readStyles`, its `complete` flag, and the three-valued `styles` argument from #457/#461: those guard a resolved file that cannot be READ, which is a filesystem question and still real. Three existing assertions flipped, deliberately. `styles: SOME_ARRAY_CONST` asserted no `styles:` key on the theory that Rust folded the constant. It does not — OXC folds string-valued consts, not array-valued ones, so that component's `ɵcmp` never had a `styles` property. `styles: []` there clears nothing. Verified against the compiler output, not reasoned. Closes #456 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx --- napi/angular-compiler/index.d.ts | 20 + napi/angular-compiler/index.js | 2 + napi/angular-compiler/src/lib.rs | 1 + .../test/hmr-hot-update.test.ts | 416 +++++++++++++++--- napi/angular-compiler/vite-plugin/index.ts | 151 +++---- 5 files changed, 458 insertions(+), 132 deletions(-) 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/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index d8e85d444..65bfcd01f 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,227 @@ 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') + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 5cf474b21..c9842d29c 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, @@ -653,17 +654,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 +691,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 +745,39 @@ 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, - ) - // `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 - } + // + // 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 `[]`. + // + // The RESOLVED list itself is never in doubt any more, so + // there is no second, weaker branch: `complete` now speaks + // only about the filesystem. + const styles: string[] | null = + external.complete || merged.length > 0 ? merged : null const result = compileForHmrSync(templateContent, className, resolvedId, styles, { angularVersion: pluginOptions.angularVersion, From 1f988537525cecebf57f3cb0e9b1249e1b0c4d7c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 25 Aug 2026 14:51:47 +0800 Subject: [PATCH 2/3] refactor(vite): delete the decorator locators the endpoint no longer uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit moved HMR resource resolution to the extractor, which left the per-class text locators reachable only from their own tests. Gone from the plugin: `extractTemplateUrlFor`, `extractClassStylesFor`, `extractInlineTemplate`, `extractInlineStyles`, and the two caches the last pair existed to fill — `inlineTemplateCache` and `inlineStylesCache`, both of which were written, pruned, refreshed on hot update, and never read by anything, from before this PR. Gone from the scanner: the `*For` locator family, `readStringLiterals`, `StringLiteralsRead`, `ClassStyleFields`, `locateStyleFieldsFor`, `hasUnreadableKey`, `hasInterpolation`. Its exports drop from 15 to 6. `stripComponentMetadata` and its closure stay untouched. It decides full reload versus hot update, so it still parses decorator text and still has to get comments, decoys and escapes right. Most of what looked dead was not. Nine of ten candidate helpers turned out live via `locateStylesInArgs` / `locateTemplateInArgs` -> `locateFieldInsideArgs` -> `findFieldInArgs`; only `hasInterpolation` was genuinely unreachable. `FieldValue` stays as `findFieldInArgs`'s return type, with its `export` dropped. So the tests were re-pointed rather than dropped: of 206, 22 are unchanged, 78 keep their title with the call swapped to a surviving locator, 26 are renamed because the old title named a concept that is gone, and 76 are deleted — the `readStringLiterals` block and the url-locator blocks, which test functions that no longer exist. Two new cases cover strip-path behaviour the salvage exposed: a `]` inside a comment must not close the array early, and an array holding only a comment must still strip to `[]`. Behaviour is proven unchanged beyond the e2e suite: `stripComponentMetadata` was run against both the old and new scanner over 744 generated sources — 62 decorator shapes by 12 file wrappers, including phantom decorators in comments and strings, CRLF, Unicode class names, malformed escapes, spreads and elisions. Zero differences. 343 unit tests pass, e2e stays at 37. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx --- .../e2e/tests/hmr-multi-component.spec.ts | 6 +- .../test/decorator-fields.test.ts | 1215 ++++------------- napi/angular-compiler/vite-plugin/index.ts | 139 +- .../vite-plugin/utils/decorator-fields.ts | 423 +----- 4 files changed, 345 insertions(+), 1438 deletions(-) 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/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/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index c9842d29c..4eea88e52 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -39,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' @@ -386,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 @@ -1004,18 +994,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) } @@ -1026,25 +1014,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)) } @@ -1295,22 +1266,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 @@ -1466,86 +1421,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 -} From 873d190a827a6b1ae8040052c54510297c9cc249 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 25 Aug 2026 15:19:48 +0800 Subject: [PATCH 3/3] fix(vite): clear styles only from the source the component compiled with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint re-parses `resolvedId` from disk, but the component was compiled from the `code` Vite handed `transform`. Those are different byte streams whenever another plugin exposes a `load` hook or a pre-ordered `transform`, and — with no third-party plugin at all — whenever `fileReplacements` points `actualId` at a different file. On a disk source the compiler never saw, every `styles` shape the extractor cannot fold resolves to nothing: an array constant, an imported one, a `.concat(...)`. Reading that as definitive emitted `styles: []` and wiped CSS the running component genuinely had — from a template edit that never touched the styles. Measured on a real dev server, disk holding `styles: STYLE_ARRAY` with an upstream `load` expanding it, editing only the external `.html`: this branch styles: [], <- wipes the compiled style main (no styles: key) <- CSS survives So this PR introduced it. It fires on the external-resource branch with no `.ts` change at all. The evidence needed was already cached. `componentMetadataCache` holds the transform-time source with the `template:` / `styles:` 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 proof 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, which is no worse than main, since main scanned the same disk source. `merged.length > 0` short-circuits, so the strip runs only when `[]` is on the table. Not done here, deliberately: the reviewer suggested caching metadata from each successful transform. `transform` does not re-run before the endpoint serves an inline `.ts` edit, so that cache is stale on the most common path. The four existing array-constant tests keep clearing, because disk and transform source are identical there. The gate separates "the compiler also saw this constant and resolved nothing", where clearing is exact, from "the compiler saw something else", where it is a guess. A new test pins the other side: identical sources, external-resource path, still clears. 347 unit tests pass, e2e stays at 37. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01665HouXD8imyMphWe1k7Dx --- .../test/hmr-hot-update.test.ts | 213 ++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 41 +++- 2 files changed, 250 insertions(+), 4 deletions(-) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 65bfcd01f..cc00e8aff 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -3150,4 +3150,217 @@ describe('@ng/component endpoint resolves the styles per class', () => { 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 4eea88e52..0acf9bb74 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -763,11 +763,44 @@ export function angular(options: PluginOptions = {}): Plugin[] { // incomplete one falls back to main's rule exactly — send // what was read when it is non-empty, and never send `[]`. // - // The RESOLVED list itself is never in doubt any more, so - // there is no second, weaker branch: `complete` now speaks - // only about the filesystem. + // `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) + ) + } const styles: string[] | null = - external.complete || merged.length > 0 ? merged : null + merged.length > 0 || (external.complete && compiledFromThisSource()) + ? merged + : null const result = compileForHmrSync(templateContent, className, resolvedId, styles, { angularVersion: pluginOptions.angularVersion,