Skip to content

Commit 223a82f

Browse files
clydinalan-agius4
authored andcommitted
perf(@angular-devkit/build-angular): use incremental bundling for component styles in esbuild builders
When using the esbuild-based builders (`esbuild-browser`/`application`) in watch mode (including `ng serve`), component stylesheets will now be incrementally rebuilt when needed. This avoids a full build of each affected component's styles during an application rebuild. Both JIT and AOT mode are supported as well as both inline and external styles.
1 parent 9e42530 commit 223a82f

File tree

4 files changed

+202
-145
lines changed

4 files changed

+202
-145
lines changed

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ import {
2828
profileSync,
2929
resetCumulativeDurations,
3030
} from '../profiling';
31-
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options';
31+
import { BundleStylesheetOptions } from '../stylesheets/bundle-options';
3232
import { AngularHostOptions } from './angular-host';
3333
import { AngularCompilation, AotCompilation, JitCompilation, NoopCompilation } from './compilation';
34+
import { ComponentStylesheetBundler } from './component-stylesheets';
3435
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';
3536
import { SourceFileCache } from './source-file-cache';
3637

@@ -106,6 +107,12 @@ export function createCompilerPlugin(
106107
// Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option
107108
let shouldTsIgnoreJs = true;
108109

110+
// Track incremental component stylesheet builds
111+
const stylesheetBundler = new ComponentStylesheetBundler(
112+
styleOptions,
113+
pluginOptions.loadResultCache,
114+
);
115+
109116
build.onStart(async () => {
110117
const result: OnStartResult = {
111118
warnings: setupWarnings,
@@ -124,17 +131,18 @@ export function createCompilerPlugin(
124131
modifiedFiles: pluginOptions.sourceFileCache?.modifiedFiles,
125132
sourceFileCache: pluginOptions.sourceFileCache,
126133
async transformStylesheet(data, containingFile, stylesheetFile) {
134+
let stylesheetResult;
135+
127136
// Stylesheet file only exists for external stylesheets
128-
const filename = stylesheetFile ?? containingFile;
129-
130-
const stylesheetResult = await bundleComponentStylesheet(
131-
styleOptions.inlineStyleLanguage,
132-
data,
133-
filename,
134-
!stylesheetFile,
135-
styleOptions,
136-
pluginOptions.loadResultCache,
137-
);
137+
if (stylesheetFile) {
138+
stylesheetResult = await stylesheetBundler.bundleFile(stylesheetFile);
139+
} else {
140+
stylesheetResult = await stylesheetBundler.bundleInline(
141+
data,
142+
containingFile,
143+
styleOptions.inlineStyleLanguage,
144+
);
145+
}
138146

139147
const { contents, resourceFiles, errors, warnings } = stylesheetResult;
140148
if (errors) {
@@ -357,9 +365,9 @@ export function createCompilerPlugin(
357365
if (pluginOptions.jit) {
358366
setupJitPluginCallbacks(
359367
build,
360-
styleOptions,
368+
stylesheetBundler,
361369
additionalOutputFiles,
362-
pluginOptions.loadResultCache,
370+
styleOptions.inlineStyleLanguage,
363371
);
364372
}
365373

@@ -379,6 +387,8 @@ export function createCompilerPlugin(
379387

380388
logCumulativeDurations();
381389
});
390+
391+
build.onDispose(() => void stylesheetBundler.dispose());
382392
},
383393
};
384394
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 { OutputFile } from 'esbuild';
10+
import { createHash } from 'node:crypto';
11+
import path from 'node:path';
12+
import { BuildOutputFileType, BundleContextResult, BundlerContext } from '../bundler-context';
13+
import { LoadResultCache } from '../load-result-cache';
14+
import {
15+
BundleStylesheetOptions,
16+
createStylesheetBundleOptions,
17+
} from '../stylesheets/bundle-options';
18+
19+
class BundlerContextCache extends Map<string, BundlerContext> {
20+
getOrCreate(key: string, creator: () => BundlerContext): BundlerContext {
21+
let value = this.get(key);
22+
23+
if (value === undefined) {
24+
value = creator();
25+
this.set(key, value);
26+
}
27+
28+
return value;
29+
}
30+
}
31+
32+
/**
33+
* Bundles component stylesheets. A stylesheet can be either an inline stylesheet that
34+
* is contained within the Component's metadata definition or an external file referenced
35+
* from the Component's metadata definition.
36+
*/
37+
export class ComponentStylesheetBundler {
38+
readonly #fileContexts = new BundlerContextCache();
39+
readonly #inlineContexts = new BundlerContextCache();
40+
41+
/**
42+
*
43+
* @param options An object containing the stylesheet bundling options.
44+
* @param cache A load result cache to use when bundling.
45+
*/
46+
constructor(
47+
private readonly options: BundleStylesheetOptions,
48+
private readonly cache?: LoadResultCache,
49+
) {}
50+
51+
async bundleFile(entry: string) {
52+
const bundlerContext = this.#fileContexts.getOrCreate(entry, () => {
53+
const buildOptions = createStylesheetBundleOptions(this.options, this.cache);
54+
buildOptions.entryPoints = [entry];
55+
56+
return new BundlerContext(this.options.workspaceRoot, true, buildOptions);
57+
});
58+
59+
return extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
60+
}
61+
62+
async bundleInline(data: string, filename: string, language: string) {
63+
// Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve
64+
// to the actual stylesheet file path.
65+
// TODO: Consider xxhash instead for hashing
66+
const id = createHash('sha256').update(data).digest('hex');
67+
68+
const bundlerContext = this.#inlineContexts.getOrCreate(id, () => {
69+
const namespace = 'angular:styles/component';
70+
const entry = [language, id, filename].join(';');
71+
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+
});
98+
99+
return new BundlerContext(this.options.workspaceRoot, true, buildOptions);
100+
});
101+
102+
// Extract the result of the bundling from the output files
103+
return extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
104+
}
105+
106+
async dispose(): Promise<void> {
107+
const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()];
108+
this.#fileContexts.clear();
109+
this.#inlineContexts.clear();
110+
111+
await Promise.allSettled(contexts.map((context) => context.dispose()));
112+
}
113+
}
114+
115+
function extractResult(result: BundleContextResult, referencedFiles?: Set<string>) {
116+
let contents = '';
117+
let map;
118+
let outputPath;
119+
const resourceFiles: OutputFile[] = [];
120+
if (!result.errors) {
121+
for (const outputFile of result.outputFiles) {
122+
const filename = path.basename(outputFile.path);
123+
if (outputFile.type === BuildOutputFileType.Media) {
124+
// The output files could also contain resources (images/fonts/etc.) that were referenced
125+
resourceFiles.push(outputFile);
126+
} else if (filename.endsWith('.css')) {
127+
outputPath = outputFile.path;
128+
contents = outputFile.text;
129+
} else if (filename.endsWith('.css.map')) {
130+
map = outputFile.text;
131+
} else {
132+
throw new Error(
133+
`Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`,
134+
);
135+
}
136+
}
137+
}
138+
139+
let metafile;
140+
if (!result.errors) {
141+
metafile = result.metafile;
142+
// Remove entryPoint fields from outputs to prevent the internal component styles from being
143+
// treated as initial files. Also mark the entry as a component resource for stat reporting.
144+
Object.values(metafile.outputs).forEach((output) => {
145+
delete output.entryPoint;
146+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
147+
(output as any)['ng-component'] = true;
148+
});
149+
}
150+
151+
return {
152+
errors: result.errors,
153+
warnings: result.warnings,
154+
contents,
155+
map,
156+
path: outputPath,
157+
resourceFiles,
158+
metafile,
159+
referencedFiles,
160+
};
161+
}

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
import type { OutputFile, PluginBuild } from 'esbuild';
1010
import { readFile } from 'node:fs/promises';
1111
import path from 'node:path';
12-
import { LoadResultCache } from '../load-result-cache';
13-
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options';
12+
import { ComponentStylesheetBundler } from './component-stylesheets';
1413
import {
1514
JIT_NAMESPACE_REGEXP,
1615
JIT_STYLE_NAMESPACE,
@@ -64,9 +63,9 @@ async function loadEntry(
6463
*/
6564
export function setupJitPluginCallbacks(
6665
build: PluginBuild,
67-
styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string },
66+
stylesheetBundler: ComponentStylesheetBundler,
6867
stylesheetResourceFiles: OutputFile[],
69-
cache?: LoadResultCache,
68+
inlineStyleLanguage: string,
7069
): void {
7170
const root = build.initialOptions.absWorkingDir ?? '';
7271

@@ -105,15 +104,20 @@ export function setupJitPluginCallbacks(
105104
// directly either via a preprocessor or esbuild itself.
106105
const entry = await loadEntry(args.path, root, true /* skipRead */);
107106

108-
const { contents, resourceFiles, errors, warnings } = await bundleComponentStylesheet(
109-
styleOptions.inlineStyleLanguage,
110-
// The `data` parameter is only needed for a stylesheet if it was inline
111-
entry.contents ?? '',
112-
entry.path,
113-
entry.contents !== undefined,
114-
styleOptions,
115-
cache,
116-
);
107+
let stylesheetResult;
108+
109+
// Stylesheet contents only exist for internal stylesheets
110+
if (entry.contents === undefined) {
111+
stylesheetResult = await stylesheetBundler.bundleFile(entry.path);
112+
} else {
113+
stylesheetResult = await stylesheetBundler.bundleInline(
114+
entry.contents,
115+
entry.path,
116+
inlineStyleLanguage,
117+
);
118+
}
119+
120+
const { contents, resourceFiles, errors, warnings } = stylesheetResult;
117121

118122
stylesheetResourceFiles.push(...resourceFiles);
119123

0 commit comments

Comments
 (0)