diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts index 2de5c5f229ca..3cb5d41de1fe 100644 --- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -198,6 +198,68 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { ); }); + it('rebuilds PostCSS stylesheet when a file is added to a plugin directory dependency', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + // Emits a class for each source file, similar to Tailwind CSS v4 content scanning + await harness.writeFile( + 'test-plugin.js', + ` + const fs = require('fs'); + const path = require('path'); + module.exports = () => { + return { + postcssPlugin: 'test-plugin', + Once(root, { result }) { + const dir = path.join(path.dirname(root.source.input.file), 'sources'); + result.messages.push({ + type: 'dir-dependency', + dir, + glob: '**/*.txt', + }); + for (const file of fs.readdirSync(dir, { recursive: true })) { + if (file.endsWith('.txt')) { + const name = fs.readFileSync(path.join(dir, file), 'utf-8').trim(); + root.append('.' + name + ' { color: red; }'); + } + } + }, + }; + }; + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './test-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', '/* base */'); + await harness.writeFile('src/sources/foo/a.txt', 'foo-class'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('.foo-class'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('.bar-class'); + + await harness.writeFile('src/sources/bar/b.txt', 'bar-class'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('.foo-class'); + harness.expectFile('dist/browser/styles.css').content.toContain('.bar-class'); + }, + ]); + }); + it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => { harness.useTarget('build', { ...BASE_OPTIONS, diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts index 402069cff1a5..c413f9375778 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -7,7 +7,7 @@ */ import type { OnLoadResult, PluginBuild } from 'esbuild'; -import { normalize } from 'node:path'; +import { dirname, normalize } from 'node:path'; export interface LoadResultCache { get(path: string): OnLoadResult | Promise | undefined; @@ -108,16 +108,27 @@ export class MemoryLoadResultCache implements LoadResultCache { } invalidate(path: string): boolean { - const affectedPaths = this.#fileDependencies.get(path); - if (!affectedPaths) { - return false; - } + let invalid = false; + + // Check parent directories to support directory dependencies (PostCSS `dir-dependency`) + let current = normalize(path); + while (true) { + const affectedPaths = this.#fileDependencies.get(current); + if (affectedPaths) { + for (const affected of affectedPaths) { + this.#loadResults.delete(affected); + } + invalid = true; + } - for (const affected of affectedPaths) { - this.#loadResults.delete(affected); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; } - return true; + return invalid; } get watchFiles(): string[] { diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts index 8a8682beed61..876308f94512 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts @@ -55,6 +55,22 @@ describe('MemoryLoadResultCache', () => { expect(cache.get('file:/test/styles.css')).toBeUndefined(); }); + it('should invalidate cached results when a file inside a dependency directory changes', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/src'], + }; + + await cache.put('file:/test/styles.css', result); + + expect(cache.invalidate('/test/other/a.ts')).toBeFalse(); + expect(cache.get('file:/test/styles.css')).toBe(result); + + expect(cache.invalidate('/test/src/app/new/a.ts')).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + }); + it('should preserve previous watch files when caching an error result', async () => { const successResult = { contents: 'body { color: red; }', diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts index 79337fe1652e..b3f320146510 100644 --- a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts @@ -401,6 +401,8 @@ async function compileString( typeof resultMessage['glob'] === 'string' ) { loadResult.watchFiles ??= []; + // Watch the directory itself so files added or moved into it later also cause a rebuild + loadResult.watchFiles.push(resultMessage['dir']); const dependencies = await glob(resultMessage['glob'], { absolute: true, cwd: resultMessage['dir'],