Skip to content

Commit fc11e53

Browse files
committed
fix(@angular/build): rebuild stylesheets when files are added to a PostCSS directory dependency
Previously, a PostCSS `dir-dependency` message (used by Tailwind CSS v4 for its content sources) was expanded with a glob and only the files that existed at build time were watched. Files created later, or moved by renaming their folder, did not invalidate the cached stylesheet, so classes used only in them were not generated until the stylesheet itself changed. The directory is now also added to the watch files, and the load result cache invalidates an entry when a changed path is inside one of its watched directories. Fixes #34446
1 parent f696eaa commit fc11e53

4 files changed

Lines changed: 99 additions & 8 deletions

File tree

‎packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,68 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
198198
);
199199
});
200200

201+
it('rebuilds PostCSS stylesheet when a file is added to a plugin directory dependency', async () => {
202+
harness.useTarget('build', {
203+
...BASE_OPTIONS,
204+
watch: true,
205+
styles: ['src/styles.css'],
206+
});
207+
208+
// Emits a class for each source file, similar to Tailwind CSS v4 content scanning
209+
await harness.writeFile(
210+
'test-plugin.js',
211+
`
212+
const fs = require('fs');
213+
const path = require('path');
214+
module.exports = () => {
215+
return {
216+
postcssPlugin: 'test-plugin',
217+
Once(root, { result }) {
218+
const dir = path.join(path.dirname(root.source.input.file), 'sources');
219+
result.messages.push({
220+
type: 'dir-dependency',
221+
dir,
222+
glob: '**/*.txt',
223+
});
224+
for (const file of fs.readdirSync(dir, { recursive: true })) {
225+
if (file.endsWith('.txt')) {
226+
const name = fs.readFileSync(path.join(dir, file), 'utf-8').trim();
227+
root.append('.' + name + ' { color: red; }');
228+
}
229+
}
230+
},
231+
};
232+
};
233+
module.exports.postcss = true;
234+
`,
235+
);
236+
await harness.writeFile(
237+
'.postcssrc.json',
238+
JSON.stringify({
239+
plugins: {
240+
'./test-plugin.js': {},
241+
},
242+
}),
243+
);
244+
await harness.writeFile('src/styles.css', '/* base */');
245+
await harness.writeFile('src/sources/foo/a.txt', 'foo-class');
246+
247+
await harness.executeWithCases([
248+
async ({ result }) => {
249+
expect(result?.success).toBe(true);
250+
harness.expectFile('dist/browser/styles.css').content.toContain('.foo-class');
251+
harness.expectFile('dist/browser/styles.css').content.not.toContain('.bar-class');
252+
253+
await harness.writeFile('src/sources/bar/b.txt', 'bar-class');
254+
},
255+
({ result }) => {
256+
expect(result?.success).toBe(true);
257+
harness.expectFile('dist/browser/styles.css').content.toContain('.foo-class');
258+
harness.expectFile('dist/browser/styles.css').content.toContain('.bar-class');
259+
},
260+
]);
261+
});
262+
201263
it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => {
202264
harness.useTarget('build', {
203265
...BASE_OPTIONS,

‎packages/angular/build/src/tools/esbuild/load-result-cache.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import type { OnLoadResult, PluginBuild } from 'esbuild';
10-
import { normalize } from 'node:path';
10+
import { dirname, normalize } from 'node:path';
1111

1212
export interface LoadResultCache {
1313
get(path: string): OnLoadResult | Promise<OnLoadResult | undefined> | undefined;
@@ -108,16 +108,27 @@ export class MemoryLoadResultCache implements LoadResultCache {
108108
}
109109

110110
invalidate(path: string): boolean {
111-
const affectedPaths = this.#fileDependencies.get(path);
112-
if (!affectedPaths) {
113-
return false;
114-
}
111+
let invalid = false;
112+
113+
// Check parent directories to support directory dependencies (PostCSS `dir-dependency`)
114+
let current = path;
115+
while (true) {
116+
const affectedPaths = this.#fileDependencies.get(current);
117+
if (affectedPaths) {
118+
for (const affected of affectedPaths) {
119+
this.#loadResults.delete(affected);
120+
}
121+
invalid = true;
122+
}
115123

116-
for (const affected of affectedPaths) {
117-
this.#loadResults.delete(affected);
124+
const parent = dirname(current);
125+
if (parent === current) {
126+
break;
127+
}
128+
current = parent;
118129
}
119130

120-
return true;
131+
return invalid;
121132
}
122133

123134
get watchFiles(): string[] {

‎packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ describe('MemoryLoadResultCache', () => {
5555
expect(cache.get('file:/test/styles.css')).toBeUndefined();
5656
});
5757

58+
it('should invalidate cached results when a file inside a dependency directory changes', async () => {
59+
const result = {
60+
contents: 'body { color: red; }',
61+
loader: 'css' as const,
62+
watchFiles: ['/test/styles.css', '/test/src'],
63+
};
64+
65+
await cache.put('file:/test/styles.css', result);
66+
67+
expect(cache.invalidate('/test/other/a.ts')).toBeFalse();
68+
expect(cache.get('file:/test/styles.css')).toBe(result);
69+
70+
expect(cache.invalidate('/test/src/app/new/a.ts')).toBeTrue();
71+
expect(cache.get('file:/test/styles.css')).toBeUndefined();
72+
});
73+
5874
it('should preserve previous watch files when caching an error result', async () => {
5975
const successResult = {
6076
contents: 'body { color: red; }',

‎packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,8 @@ async function compileString(
401401
typeof resultMessage['glob'] === 'string'
402402
) {
403403
loadResult.watchFiles ??= [];
404+
// Watch the directory itself so files added or moved into it later also cause a rebuild
405+
loadResult.watchFiles.push(resultMessage['dir']);
404406
const dependencies = await glob(resultMessage['glob'], {
405407
absolute: true,
406408
cwd: resultMessage['dir'],

0 commit comments

Comments
 (0)