Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(@angular-devkit/build-angular): use incremental bundling for component styles in esbuild builders #25965

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -28,9 +28,10 @@ import {
profileSync,
resetCumulativeDurations,
} from '../profiling';
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options';
import { BundleStylesheetOptions } from '../stylesheets/bundle-options';
import { AngularHostOptions } from './angular-host';
import { AngularCompilation, AotCompilation, JitCompilation, NoopCompilation } from './compilation';
import { ComponentStylesheetBundler } from './component-stylesheets';
import { setupJitPluginCallbacks } from './jit-plugin-callbacks';

const USING_WINDOWS = platform() === 'win32';
Expand Down Expand Up @@ -138,6 +139,12 @@ export function createCompilerPlugin(
// Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option
let shouldTsIgnoreJs = true;

// Track incremental component stylesheet builds
const stylesheetBundler = new ComponentStylesheetBundler(
styleOptions,
pluginOptions.loadResultCache,
);

build.onStart(async () => {
const result: OnStartResult = {
warnings: setupWarnings,
Expand All @@ -156,17 +163,18 @@ export function createCompilerPlugin(
modifiedFiles: pluginOptions.sourceFileCache?.modifiedFiles,
sourceFileCache: pluginOptions.sourceFileCache,
async transformStylesheet(data, containingFile, stylesheetFile) {
let stylesheetResult;

// Stylesheet file only exists for external stylesheets
const filename = stylesheetFile ?? containingFile;

const stylesheetResult = await bundleComponentStylesheet(
styleOptions.inlineStyleLanguage,
data,
filename,
!stylesheetFile,
styleOptions,
pluginOptions.loadResultCache,
);
if (stylesheetFile) {
stylesheetResult = await stylesheetBundler.bundleFile(stylesheetFile);
} else {
stylesheetResult = await stylesheetBundler.bundleInline(
data,
containingFile,
styleOptions.inlineStyleLanguage,
);
}

const { contents, resourceFiles, errors, warnings } = stylesheetResult;
if (errors) {
Expand Down Expand Up @@ -383,9 +391,9 @@ export function createCompilerPlugin(
if (pluginOptions.jit) {
setupJitPluginCallbacks(
build,
styleOptions,
stylesheetBundler,
additionalOutputFiles,
pluginOptions.loadResultCache,
styleOptions.inlineStyleLanguage,
);
}

Expand All @@ -405,6 +413,8 @@ export function createCompilerPlugin(

logCumulativeDurations();
});

build.onDispose(() => void stylesheetBundler.dispose());
},
};
}
Expand Down
@@ -0,0 +1,161 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { OutputFile } from 'esbuild';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { BuildOutputFileType, BundleContextResult, BundlerContext } from '../bundler-context';
import { LoadResultCache } from '../load-result-cache';
import {
BundleStylesheetOptions,
createStylesheetBundleOptions,
} from '../stylesheets/bundle-options';

class BundlerContextCache extends Map<string, BundlerContext> {
getOrCreate(key: string, creator: () => BundlerContext): BundlerContext {
let value = this.get(key);

if (value === undefined) {
value = creator();
this.set(key, value);
}

return value;
}
}

/**
* Bundles component stylesheets. A stylesheet can be either an inline stylesheet that
* is contained within the Component's metadata definition or an external file referenced
* from the Component's metadata definition.
*/
export class ComponentStylesheetBundler {
readonly #fileContexts = new BundlerContextCache();
readonly #inlineContexts = new BundlerContextCache();

/**
*
* @param options An object containing the stylesheet bundling options.
* @param cache A load result cache to use when bundling.
*/
constructor(
private readonly options: BundleStylesheetOptions,
private readonly cache?: LoadResultCache,
) {}

async bundleFile(entry: string) {
const bundlerContext = this.#fileContexts.getOrCreate(entry, () => {
const buildOptions = createStylesheetBundleOptions(this.options, this.cache);
buildOptions.entryPoints = [entry];

return new BundlerContext(this.options.workspaceRoot, true, buildOptions);
});

return extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
}

async bundleInline(data: string, filename: string, language: string) {
// Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve
// to the actual stylesheet file path.
// TODO: Consider xxhash instead for hashing
const id = createHash('sha256').update(data).digest('hex');

const bundlerContext = this.#inlineContexts.getOrCreate(id, () => {
const namespace = 'angular:styles/component';
const entry = [language, id, filename].join(';');

const buildOptions = createStylesheetBundleOptions(this.options, this.cache, {
[entry]: data,
});
buildOptions.entryPoints = [`${namespace};${entry}`];
buildOptions.plugins.push({
name: 'angular-component-styles',
setup(build) {
build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => {
if (args.kind !== 'entry-point') {
return null;
}

return {
path: entry,
namespace,
};
});
build.onLoad({ filter: /^css;/, namespace }, async () => {
return {
contents: data,
loader: 'css',
resolveDir: path.dirname(filename),
};
});
},
});

return new BundlerContext(this.options.workspaceRoot, true, buildOptions);
});

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

async dispose(): Promise<void> {
const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()];
this.#fileContexts.clear();
this.#inlineContexts.clear();

await Promise.allSettled(contexts.map((context) => context.dispose()));
}
}

function extractResult(result: BundleContextResult, referencedFiles?: Set<string>) {
let contents = '';
let map;
let outputPath;
const resourceFiles: OutputFile[] = [];
if (!result.errors) {
for (const outputFile of result.outputFiles) {
const filename = path.basename(outputFile.path);
if (outputFile.type === BuildOutputFileType.Media) {
// The output files could also contain resources (images/fonts/etc.) that were referenced
resourceFiles.push(outputFile);
} else if (filename.endsWith('.css')) {
outputPath = outputFile.path;
contents = outputFile.text;
} else if (filename.endsWith('.css.map')) {
map = outputFile.text;
} else {
throw new Error(
`Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`,
);
}
}
}

let metafile;
if (!result.errors) {
metafile = result.metafile;
// Remove entryPoint fields from outputs to prevent the internal component styles from being
// treated as initial files. Also mark the entry as a component resource for stat reporting.
Object.values(metafile.outputs).forEach((output) => {
delete output.entryPoint;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(output as any)['ng-component'] = true;
});
}

return {
errors: result.errors,
warnings: result.warnings,
contents,
map,
path: outputPath,
resourceFiles,
metafile,
referencedFiles,
};
}
Expand Up @@ -9,8 +9,7 @@
import type { OutputFile, PluginBuild } from 'esbuild';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { LoadResultCache } from '../load-result-cache';
import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options';
import { ComponentStylesheetBundler } from './component-stylesheets';
import {
JIT_NAMESPACE_REGEXP,
JIT_STYLE_NAMESPACE,
Expand Down Expand Up @@ -64,9 +63,9 @@ async function loadEntry(
*/
export function setupJitPluginCallbacks(
build: PluginBuild,
styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string },
stylesheetBundler: ComponentStylesheetBundler,
stylesheetResourceFiles: OutputFile[],
cache?: LoadResultCache,
inlineStyleLanguage: string,
): void {
const root = build.initialOptions.absWorkingDir ?? '';

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

const { contents, resourceFiles, errors, warnings } = await bundleComponentStylesheet(
styleOptions.inlineStyleLanguage,
// The `data` parameter is only needed for a stylesheet if it was inline
entry.contents ?? '',
entry.path,
entry.contents !== undefined,
styleOptions,
cache,
);
let stylesheetResult;

// Stylesheet contents only exist for internal stylesheets
if (entry.contents === undefined) {
stylesheetResult = await stylesheetBundler.bundleFile(entry.path);
} else {
stylesheetResult = await stylesheetBundler.bundleInline(
entry.contents,
entry.path,
inlineStyleLanguage,
);
}

const { contents, resourceFiles, errors, warnings } = stylesheetResult;

stylesheetResourceFiles.push(...resourceFiles);

Expand Down