Skip to content

Commit

Permalink
refactor(@angular/build): support external runtime component styleshe…
Browse files Browse the repository at this point in the history
…ets in application builder

To support automatic component style HMR, `application` builder in development mode now
provides support for generating external runtime component stylesheets. This capability
leverages the upcoming support within the AOT -compiler to emit components that generate
`link` elements instead of embedding the stylesheet contents for file-based styles
(e.g., `styleUrl`). In combination with support within the development server to handle
requests for component stylesheets, file-based component stylesheets will be able to be
replaced without a full page reload.

The implementation leverages the AOT compiler option `externalRuntimeStyles` which uses
the result of the resource handler's resolution and emits new external stylesheet metadata
within the component output code. This new metadata works in concert with the Angular runtime
to generate `link` elements which can then leverage existing global stylesheet HMR capabilities.

This capability is current disabled by default while all elements are integrated across the
CLI and framework and can be controlled via the `NG_HMR_CSTYLES=1` environment variable.
Once fully integrated the environment variable will unneeded.

This feature is only intended for use with the development server. Component styles within
in built code including production are not affected by this feature.

NOTE: Rebuild times have not yet been optimized. Future improvements will reduce the component
stylesheet only rebuild time case.
  • Loading branch information
clydin committed Aug 30, 2024
1 parent 58fcf28 commit 8135c60
Show file tree
Hide file tree
Showing 12 changed files with 154 additions and 12 deletions.
10 changes: 10 additions & 0 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ interface InternalOptions {
* This is only used by the development server which currently only supports a single locale per build.
*/
forceI18nFlatOutput?: boolean;

/**
* Enables the use of AOT compiler emitted external runtime styles.
* External runtime styles use `link` elements instead of embedded style content in the output JavaScript.
* This option is only intended to be used with a development server that can process and serve component
* styles.
*/
externalRuntimeStyles?: boolean;
}

/** Full set of options for `application` builder. */
Expand Down Expand Up @@ -315,6 +323,7 @@ export async function normalizeOptions(
deployUrl,
clearScreen,
define,
externalRuntimeStyles,
} = options;

// Return all the normalized options
Expand Down Expand Up @@ -374,6 +383,7 @@ export async function normalizeOptions(
colors: supportColor(),
clearScreen,
define,
externalRuntimeStyles,
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/angular/build/src/builders/dev-server/vite-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugi
import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin';
import { createRemoveIdPrefixPlugin } from '../../tools/vite/id-prefix-plugin';
import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils';
import { useComponentStyleHmr } from '../../utils/environment-options';
import { loadEsmModule } from '../../utils/load-esm';
import { Result, ResultFile, ResultKind } from '../application/results';
import {
Expand Down Expand Up @@ -116,6 +117,9 @@ export async function* serveWithVite(
browserOptions.forceI18nFlatOutput = true;
}

// TODO: Enable by default once full support across CLI and FW is integrated
browserOptions.externalRuntimeStyles = useComponentStyleHmr;

const { vendor: thirdPartySourcemaps } = normalizeSourceMaps(browserOptions.sourceMap ?? false);

// Setup the prebundling transformer that will be shared across Vite prebundling requests
Expand Down
25 changes: 25 additions & 0 deletions packages/angular/build/src/tools/angular/angular-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type ng from '@angular/compiler-cli';
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import nodePath from 'node:path';
import type ts from 'typescript';
Expand All @@ -18,6 +19,7 @@ export interface AngularHostOptions {
fileReplacements?: Record<string, string>;
sourceFileCache?: Map<string, ts.SourceFile>;
modifiedFiles?: Set<string>;
externalStylesheets?: Map<string, string>;
transformStylesheet(
data: string,
containingFile: string,
Expand Down Expand Up @@ -180,6 +182,11 @@ export function createAngularCompilerHost(
return null;
}

assert(
!context.resourceFile || !hostOptions.externalStylesheets?.has(context.resourceFile),
'External runtime stylesheets should not be transformed: ' + context.resourceFile,
);

// No transformation required if the resource is empty
if (data.trim().length === 0) {
return { content: '' };
Expand All @@ -194,6 +201,24 @@ export function createAngularCompilerHost(
return typeof result === 'string' ? { content: result } : null;
};

host.resourceNameToFileName = function (resourceName, containingFile) {
const resolvedPath = nodePath.join(nodePath.dirname(containingFile), resourceName);

// All resource names that have HTML file extensions are assumed to be templates
if (resourceName.endsWith('.html') || !hostOptions.externalStylesheets) {
return resolvedPath;
}

// For external stylesheets, create a unique identifier and store the mapping
let externalId = hostOptions.externalStylesheets.get(resolvedPath);
if (externalId === undefined) {
externalId = createHash('sha256').update(resolvedPath).digest('hex');
hostOptions.externalStylesheets.set(resolvedPath, externalId);
}

return externalId + '.css';
};

// Allow the AOT compiler to request the set of changed templates and styles
host.getModifiedResourceFiles = function () {
return hostOptions.modifiedFiles;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export abstract class AngularCompilation {
affectedFiles: ReadonlySet<ts.SourceFile>;
compilerOptions: ng.CompilerOptions;
referencedFiles: readonly string[];
externalStylesheets?: ReadonlyMap<string, string>;
}>;

abstract emitAffectedFiles(): Iterable<EmitFileResult> | Promise<Iterable<EmitFileResult>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class AotCompilation extends AngularCompilation {
affectedFiles: ReadonlySet<ts.SourceFile>;
compilerOptions: ng.CompilerOptions;
referencedFiles: readonly string[];
externalStylesheets?: ReadonlyMap<string, string>;
}> {
// Dynamically load the Angular compiler CLI package
const { NgtscProgram, OptimizeFor } = await AngularCompilation.loadCompilerCli();
Expand All @@ -59,6 +60,10 @@ export class AotCompilation extends AngularCompilation {
const compilerOptions =
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;

if (compilerOptions.externalRuntimeStyles) {
hostOptions.externalStylesheets ??= new Map();
}

// Create Angular compiler host
const host = createAngularCompilerHost(ts, compilerOptions, hostOptions);

Expand Down Expand Up @@ -121,7 +126,12 @@ export class AotCompilation extends AngularCompilation {
this.#state?.diagnosticCache,
);

return { affectedFiles, compilerOptions, referencedFiles };
return {
affectedFiles,
compilerOptions,
referencedFiles,
externalStylesheets: hostOptions.externalStylesheets,
};
}

*collectDiagnostics(modes: DiagnosticModes): Iterable<ts.Diagnostic> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class ParallelCompilation extends AngularCompilation {
affectedFiles: ReadonlySet<SourceFile>;
compilerOptions: CompilerOptions;
referencedFiles: readonly string[];
externalStylesheets?: ReadonlyMap<string, string>;
}> {
const stylesheetChannel = new MessageChannel();
// The request identifier is required because Angular can issue multiple concurrent requests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function initialize(request: InitRequest) {
}
});

const { compilerOptions, referencedFiles } = await compilation.initialize(
const { compilerOptions, referencedFiles, externalStylesheets } = await compilation.initialize(
request.tsconfig,
{
fileReplacements: request.fileReplacements,
Expand Down Expand Up @@ -93,6 +93,7 @@ export async function initialize(request: InitRequest) {
);

return {
externalStylesheets,
referencedFiles,
// TODO: Expand? `allowJs`, `isolatedModules`, `sourceMap`, `inlineSourceMap` are the only fields needed currently.
compilerOptions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface CompilerPluginOptions {
sourceFileCache?: SourceFileCache;
loadResultCache?: LoadResultCache;
incremental: boolean;
externalRuntimeStyles?: boolean;
}

// eslint-disable-next-line max-lines-per-function
Expand Down Expand Up @@ -151,6 +152,7 @@ export function createCompilerPlugin(
// Angular compiler which does not have direct knowledge of transitive resource
// dependencies or web worker processing.
let modifiedFiles;
let invalidatedStylesheetEntries;
if (
pluginOptions.sourceFileCache?.modifiedFiles.size &&
referencedFileTracker &&
Expand All @@ -159,7 +161,7 @@ export function createCompilerPlugin(
// TODO: Differentiate between changed input files and stale output files
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
stylesheetBundler.invalidate(modifiedFiles);
invalidatedStylesheetEntries = stylesheetBundler.invalidate(modifiedFiles);
}

if (
Expand Down Expand Up @@ -265,6 +267,7 @@ export function createCompilerPlugin(
// Initialize the Angular compilation for the current build.
// In watch mode, previous build state will be reused.
let referencedFiles;
let externalStylesheets;
try {
const initializationResult = await compilation.initialize(
pluginOptions.tsconfig,
Expand All @@ -279,6 +282,7 @@ export function createCompilerPlugin(
!!initializationResult.compilerOptions.sourceMap ||
!!initializationResult.compilerOptions.inlineSourceMap;
referencedFiles = initializationResult.referencedFiles;
externalStylesheets = initializationResult.externalStylesheets;
} catch (error) {
(result.errors ??= []).push({
text: 'Angular compilation initialization failed.',
Expand All @@ -303,6 +307,40 @@ export function createCompilerPlugin(
return result;
}

if (externalStylesheets) {
// Process any new external stylesheets
for (const [stylesheetFile, externalId] of externalStylesheets) {
const { outputFiles, metafile, errors, warnings } = await stylesheetBundler.bundleFile(
stylesheetFile,
externalId,
);
if (errors) {
(result.errors ??= []).push(...errors);
}
(result.warnings ??= []).push(...warnings);
additionalResults.set(stylesheetFile, {
outputFiles,
metafile,
});
}
// Process any updated stylesheets
if (invalidatedStylesheetEntries) {
for (const stylesheetFile of invalidatedStylesheetEntries) {
// externalId is already linked in the bundler context so only enabling is required here
const { outputFiles, metafile, errors, warnings } =
await stylesheetBundler.bundleFile(stylesheetFile, true);
if (errors) {
(result.errors ??= []).push(...errors);
}
(result.warnings ??= []).push(...warnings);
additionalResults.set(stylesheetFile, {
outputFiles,
metafile,
});
}
}
}

// Update TypeScript file output cache for all affected files
try {
await profileAsync('NG_EMIT_TS', async () => {
Expand Down Expand Up @@ -571,6 +609,7 @@ function createCompilerOptionsTransformer(
mapRoot: undefined,
sourceRoot: undefined,
preserveSymlinks,
externalRuntimeStyles: pluginOptions.externalRuntimeStyles,
};
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { OutputFile } from 'esbuild';
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { BuildOutputFileType, BundleContextResult, BundlerContext } from '../bundler-context';
Expand Down Expand Up @@ -35,17 +36,31 @@ export class ComponentStylesheetBundler {
private readonly incremental: boolean,
) {}

async bundleFile(entry: string) {
async bundleFile(entry: string, externalId?: string | boolean) {
const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => {
return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => {
const buildOptions = createStylesheetBundleOptions(this.options, loadCache);
buildOptions.entryPoints = [entry];
if (externalId) {
assert(
typeof externalId === 'string',
'Initial external component stylesheets must have a string identifier',
);

buildOptions.entryPoints = { [externalId]: entry };
delete buildOptions.publicPath;
} else {
buildOptions.entryPoints = [entry];
}

return buildOptions;
});
});

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

async bundleInline(data: string, filename: string, language: string) {
Expand Down Expand Up @@ -91,22 +106,33 @@ export class ComponentStylesheetBundler {
});

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

invalidate(files: Iterable<string>) {
/**
* Invalidates both file and inline based component style bundling state for a set of modified files.
* @param files The group of files that have been modified
* @returns An array of file based stylesheet entries if any were invalidated; otherwise, undefined.
*/
invalidate(files: Iterable<string>): string[] | undefined {
if (!this.incremental) {
return;
}

const normalizedFiles = [...files].map(path.normalize);
let entries: string[] | undefined;

for (const bundler of this.#fileContexts.values()) {
bundler.invalidate(normalizedFiles);
for (const [entry, bundler] of this.#fileContexts.entries()) {
if (bundler.invalidate(normalizedFiles)) {
entries ??= [];
entries.push(entry);
}
}
for (const bundler of this.#inlineContexts.values()) {
bundler.invalidate(normalizedFiles);
}

return entries;
}

async dispose(): Promise<void> {
Expand All @@ -117,7 +143,11 @@ export class ComponentStylesheetBundler {
await Promise.allSettled(contexts.map((context) => context.dispose()));
}

private extractResult(result: BundleContextResult, referencedFiles?: Set<string>) {
private extractResult(
result: BundleContextResult,
referencedFiles: Set<string> | undefined,
external: boolean,
) {
let contents = '';
let metafile;
const outputFiles: OutputFile[] = [];
Expand All @@ -140,7 +170,14 @@ export class ComponentStylesheetBundler {

outputFiles.push(clonedOutputFile);
} else if (filename.endsWith('.css')) {
contents = outputFile.text;
if (external) {
const clonedOutputFile = outputFile.clone();
clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path);
outputFiles.push(clonedOutputFile);
contents = path.posix.join(this.options.publicPath ?? '', filename);
} else {
contents = outputFile.text;
}
} else {
throw new Error(
`Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`,
Expand Down
8 changes: 8 additions & 0 deletions packages/angular/build/src/tools/esbuild/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,12 @@ export class MemoryCache<V> extends Cache<V, Map<string, V>> {
values() {
return this.store.values();
}

/**
* Provides all the keys/values currently present in the cache instance.
* @returns An iterable of all key/value pairs in the cache.
*/
entries() {
return this.store.entries();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function createCompilerPluginOptions(
tailwindConfiguration,
postcssConfiguration,
publicPath,
externalRuntimeStyles,
} = options;

return {
Expand All @@ -51,6 +52,7 @@ export function createCompilerPluginOptions(
sourceFileCache,
loadResultCache: sourceFileCache?.loadResultCache,
incremental: !!options.watch,
externalRuntimeStyles,
},
// Component stylesheet options
styleOptions: {
Expand Down
Loading

0 comments on commit 8135c60

Please sign in to comment.