Skip to content

Commit ca4d163

Browse files
committed
fix(@angular-devkit/build-angular): use component style load result caching information for file watching
When using the esbuild-based builders (`application`/`browser-esbuild`) in watch mode including `ng serve`, component stylesheets that used Sass and imported other stylesheets were previously no properly tracked. As a result, changes to the imported stylesheets would not invalidate the component and the rebuild would not contain the updated styles. The files used by the Sass (and Less) stylesheet processing are now correctly tracked in these cases. (cherry picked from commit 7229a3d)
1 parent 254a680 commit ca4d163

File tree

5 files changed

+185
-40
lines changed

5 files changed

+185
-40
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { concatMap, count, timeout } from 'rxjs';
10+
import { buildApplication } from '../../index';
11+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
12+
13+
/**
14+
* Maximum time in milliseconds for single build/rebuild
15+
* This accounts for CI variability.
16+
*/
17+
export const BUILD_TIMEOUT = 30_000;
18+
19+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
20+
describe('Behavior: "Rebuilds when component stylesheets change"', () => {
21+
it('updates component when imported sass changes', async () => {
22+
harness.useTarget('build', {
23+
...BASE_OPTIONS,
24+
watch: true,
25+
});
26+
27+
await harness.modifyFile('src/app/app.component.ts', (content) =>
28+
content.replace('app.component.css', 'app.component.scss'),
29+
);
30+
await harness.writeFile('src/app/app.component.scss', "@import './a';");
31+
await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }');
32+
33+
const builderAbort = new AbortController();
34+
const buildCount = await harness
35+
.execute({ signal: builderAbort.signal })
36+
.pipe(
37+
timeout(30000),
38+
concatMap(async ({ result }, index) => {
39+
expect(result?.success).toBe(true);
40+
41+
switch (index) {
42+
case 0:
43+
harness.expectFile('dist/browser/main.js').content.toContain('color: aqua');
44+
harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue');
45+
46+
await harness.writeFile(
47+
'src/app/a.scss',
48+
'$primary: blue;\\nh1 { color: $primary; }',
49+
);
50+
break;
51+
case 1:
52+
harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua');
53+
harness.expectFile('dist/browser/main.js').content.toContain('color: blue');
54+
55+
await harness.writeFile(
56+
'src/app/a.scss',
57+
'$primary: green;\\nh1 { color: $primary; }',
58+
);
59+
break;
60+
case 2:
61+
harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua');
62+
harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue');
63+
harness.expectFile('dist/browser/main.js').content.toContain('color: green');
64+
65+
// Test complete - abort watch mode
66+
builderAbort.abort();
67+
break;
68+
}
69+
}),
70+
count(),
71+
)
72+
.toPromise();
73+
74+
expect(buildCount).toBe(3);
75+
});
76+
});
77+
});

packages/angular_devkit/build_angular/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ export function createCompilerPlugin(
9898
const stylesheetBundler = new ComponentStylesheetBundler(
9999
styleOptions,
100100
pluginOptions.incremental,
101-
pluginOptions.loadResultCache,
102101
);
103102
let sharedTSCompilationState: SharedTSCompilationState | undefined;
104103

@@ -131,6 +130,7 @@ export function createCompilerPlugin(
131130
// TODO: Differentiate between changed input files and stale output files
132131
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
133132
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
133+
stylesheetBundler.invalidate(modifiedFiles);
134134
}
135135

136136
if (

packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { OutputFile } from 'esbuild';
1010
import { createHash } from 'node:crypto';
1111
import path from 'node:path';
1212
import { BuildOutputFileType, BundleContextResult, BundlerContext } from '../bundler-context';
13-
import { LoadResultCache } from '../load-result-cache';
1413
import {
1514
BundleStylesheetOptions,
1615
createStylesheetBundleOptions,
@@ -46,15 +45,16 @@ export class ComponentStylesheetBundler {
4645
constructor(
4746
private readonly options: BundleStylesheetOptions,
4847
private readonly incremental: boolean,
49-
private readonly cache?: LoadResultCache,
5048
) {}
5149

5250
async bundleFile(entry: string) {
5351
const bundlerContext = this.#fileContexts.getOrCreate(entry, () => {
54-
const buildOptions = createStylesheetBundleOptions(this.options, this.cache);
55-
buildOptions.entryPoints = [entry];
52+
return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => {
53+
const buildOptions = createStylesheetBundleOptions(this.options, loadCache);
54+
buildOptions.entryPoints = [entry];
5655

57-
return new BundlerContext(this.options.workspaceRoot, this.incremental, buildOptions);
56+
return buildOptions;
57+
});
5858
});
5959

6060
return extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
@@ -69,40 +69,56 @@ export class ComponentStylesheetBundler {
6969

7070
const bundlerContext = this.#inlineContexts.getOrCreate(entry, () => {
7171
const namespace = 'angular:styles/component';
72-
const buildOptions = createStylesheetBundleOptions(this.options, this.cache, {
73-
[entry]: data,
74-
});
75-
buildOptions.entryPoints = [`${namespace};${entry}`];
76-
buildOptions.plugins.push({
77-
name: 'angular-component-styles',
78-
setup(build) {
79-
build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => {
80-
if (args.kind !== 'entry-point') {
81-
return null;
82-
}
83-
84-
return {
85-
path: entry,
86-
namespace,
87-
};
88-
});
89-
build.onLoad({ filter: /^css;/, namespace }, async () => {
90-
return {
91-
contents: data,
92-
loader: 'css',
93-
resolveDir: path.dirname(filename),
94-
};
95-
});
96-
},
97-
});
9872

99-
return new BundlerContext(this.options.workspaceRoot, this.incremental, buildOptions);
73+
return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => {
74+
const buildOptions = createStylesheetBundleOptions(this.options, loadCache, {
75+
[entry]: data,
76+
});
77+
buildOptions.entryPoints = [`${namespace};${entry}`];
78+
buildOptions.plugins.push({
79+
name: 'angular-component-styles',
80+
setup(build) {
81+
build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => {
82+
if (args.kind !== 'entry-point') {
83+
return null;
84+
}
85+
86+
return {
87+
path: entry,
88+
namespace,
89+
};
90+
});
91+
build.onLoad({ filter: /^css;/, namespace }, async () => {
92+
return {
93+
contents: data,
94+
loader: 'css',
95+
resolveDir: path.dirname(filename),
96+
};
97+
});
98+
},
99+
});
100+
101+
return buildOptions;
102+
});
100103
});
101104

102105
// Extract the result of the bundling from the output files
103106
return extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
104107
}
105108

109+
invalidate(files: Iterable<string>) {
110+
if (!this.incremental) {
111+
return;
112+
}
113+
114+
for (const bundler of this.#fileContexts.values()) {
115+
bundler.invalidate(files);
116+
}
117+
for (const bundler of this.#inlineContexts.values()) {
118+
bundler.invalidate(files);
119+
}
120+
}
121+
106122
async dispose(): Promise<void> {
107123
const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()];
108124
this.#fileContexts.clear();

packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
context,
1818
} from 'esbuild';
1919
import { basename, dirname, extname, join, relative } from 'node:path';
20+
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
2021
import { convertOutputFile } from './utils';
2122

2223
export type BundleContextResult =
@@ -49,6 +50,10 @@ export interface BuildOutputFile extends OutputFile {
4950
clone: () => BuildOutputFile;
5051
}
5152

53+
export type BundlerOptionsFactory<T extends BuildOptions = BuildOptions> = (
54+
loadCache: LoadResultCache | undefined,
55+
) => T;
56+
5257
/**
5358
* Determines if an unknown value is an esbuild BuildFailure error object thrown by esbuild.
5459
* @param value A potential esbuild BuildFailure error object.
@@ -60,20 +65,26 @@ function isEsBuildFailure(value: unknown): value is BuildFailure {
6065

6166
export class BundlerContext {
6267
#esbuildContext?: BuildContext<{ metafile: true; write: false }>;
63-
#esbuildOptions: BuildOptions & { metafile: true; write: false };
68+
#esbuildOptions?: BuildOptions & { metafile: true; write: false };
69+
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
6470

71+
#loadCache?: MemoryLoadResultCache;
6572
readonly watchFiles = new Set<string>();
6673

6774
constructor(
6875
private workspaceRoot: string,
6976
private incremental: boolean,
70-
options: BuildOptions,
77+
options: BuildOptions | BundlerOptionsFactory,
7178
private initialFilter?: (initial: Readonly<InitialFileRecord>) => boolean,
7279
) {
73-
this.#esbuildOptions = {
74-
...options,
75-
metafile: true,
76-
write: false,
80+
this.#optionsFactory = (...args) => {
81+
const baseOptions = typeof options === 'function' ? options(...args) : options;
82+
83+
return {
84+
...baseOptions,
85+
metafile: true,
86+
write: false,
87+
};
7788
};
7889
}
7990

@@ -131,6 +142,14 @@ export class BundlerContext {
131142
* warnings and errors for the attempted build.
132143
*/
133144
async bundle(): Promise<BundleContextResult> {
145+
// Create esbuild options if not present
146+
if (this.#esbuildOptions === undefined) {
147+
if (this.incremental) {
148+
this.#loadCache = new MemoryLoadResultCache();
149+
}
150+
this.#esbuildOptions = this.#optionsFactory(this.#loadCache);
151+
}
152+
134153
let result;
135154
try {
136155
if (this.#esbuildContext) {
@@ -162,7 +181,15 @@ export class BundlerContext {
162181
// Add input files except virtual angular files which do not exist on disk
163182
Object.keys(result.metafile.inputs)
164183
.filter((input) => !input.startsWith('angular:'))
184+
// input file paths are always relative to the workspace root
165185
.forEach((input) => this.watchFiles.add(join(this.workspaceRoot, input)));
186+
// Also add any files from the load result cache
187+
if (this.#loadCache) {
188+
this.#loadCache.watchFiles
189+
.filter((file) => !file.startsWith('angular:'))
190+
// watch files are fully resolved paths
191+
.forEach((file) => this.watchFiles.add(file));
192+
}
166193
}
167194

168195
// Return if the build encountered any errors
@@ -254,14 +281,34 @@ export class BundlerContext {
254281
};
255282
}
256283

284+
invalidate(files: Iterable<string>): boolean {
285+
if (!this.incremental) {
286+
return false;
287+
}
288+
289+
let invalid = false;
290+
for (const file of files) {
291+
if (this.#loadCache?.invalidate(file)) {
292+
invalid = true;
293+
continue;
294+
}
295+
296+
invalid ||= this.watchFiles.has(file);
297+
}
298+
299+
return invalid;
300+
}
301+
257302
/**
258303
* Disposes incremental build resources present in the context.
259304
*
260305
* @returns A promise that resolves when disposal is complete.
261306
*/
262307
async dispose(): Promise<void> {
263308
try {
264-
return this.#esbuildContext?.dispose();
309+
this.#esbuildOptions = undefined;
310+
this.#loadCache = undefined;
311+
await this.#esbuildContext?.dispose();
265312
} finally {
266313
this.#esbuildContext = undefined;
267314
}

packages/angular_devkit/build_angular/src/tools/esbuild/load-result-cache.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { normalize } from 'node:path';
1212
export interface LoadResultCache {
1313
get(path: string): OnLoadResult | undefined;
1414
put(path: string, result: OnLoadResult): Promise<void>;
15+
readonly watchFiles: ReadonlyArray<string>;
1516
}
1617

1718
export function createCachedLoad(
@@ -76,4 +77,8 @@ export class MemoryLoadResultCache implements LoadResultCache {
7677

7778
return found;
7879
}
80+
81+
get watchFiles(): string[] {
82+
return [...this.#loadResults.keys(), ...this.#fileDependencies.keys()];
83+
}
7984
}

0 commit comments

Comments
 (0)