Skip to content

Commit 8135c60

Browse files
committed
refactor(@angular/build): support external runtime component stylesheets 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.
1 parent 58fcf28 commit 8135c60

File tree

12 files changed

+154
-12
lines changed

12 files changed

+154
-12
lines changed

packages/angular/build/src/builders/application/options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ interface InternalOptions {
6666
* This is only used by the development server which currently only supports a single locale per build.
6767
*/
6868
forceI18nFlatOutput?: boolean;
69+
70+
/**
71+
* Enables the use of AOT compiler emitted external runtime styles.
72+
* External runtime styles use `link` elements instead of embedded style content in the output JavaScript.
73+
* This option is only intended to be used with a development server that can process and serve component
74+
* styles.
75+
*/
76+
externalRuntimeStyles?: boolean;
6977
}
7078

7179
/** Full set of options for `application` builder. */
@@ -315,6 +323,7 @@ export async function normalizeOptions(
315323
deployUrl,
316324
clearScreen,
317325
define,
326+
externalRuntimeStyles,
318327
} = options;
319328

320329
// Return all the normalized options
@@ -374,6 +383,7 @@ export async function normalizeOptions(
374383
colors: supportColor(),
375384
clearScreen,
376385
define,
386+
externalRuntimeStyles,
377387
};
378388
}
379389

packages/angular/build/src/builders/dev-server/vite-server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugi
1717
import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin';
1818
import { createRemoveIdPrefixPlugin } from '../../tools/vite/id-prefix-plugin';
1919
import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils';
20+
import { useComponentStyleHmr } from '../../utils/environment-options';
2021
import { loadEsmModule } from '../../utils/load-esm';
2122
import { Result, ResultFile, ResultKind } from '../application/results';
2223
import {
@@ -116,6 +117,9 @@ export async function* serveWithVite(
116117
browserOptions.forceI18nFlatOutput = true;
117118
}
118119

120+
// TODO: Enable by default once full support across CLI and FW is integrated
121+
browserOptions.externalRuntimeStyles = useComponentStyleHmr;
122+
119123
const { vendor: thirdPartySourcemaps } = normalizeSourceMaps(browserOptions.sourceMap ?? false);
120124

121125
// Setup the prebundling transformer that will be shared across Vite prebundling requests

packages/angular/build/src/tools/angular/angular-host.ts

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

99
import type ng from '@angular/compiler-cli';
10+
import assert from 'node:assert';
1011
import { createHash } from 'node:crypto';
1112
import nodePath from 'node:path';
1213
import type ts from 'typescript';
@@ -18,6 +19,7 @@ export interface AngularHostOptions {
1819
fileReplacements?: Record<string, string>;
1920
sourceFileCache?: Map<string, ts.SourceFile>;
2021
modifiedFiles?: Set<string>;
22+
externalStylesheets?: Map<string, string>;
2123
transformStylesheet(
2224
data: string,
2325
containingFile: string,
@@ -180,6 +182,11 @@ export function createAngularCompilerHost(
180182
return null;
181183
}
182184

185+
assert(
186+
!context.resourceFile || !hostOptions.externalStylesheets?.has(context.resourceFile),
187+
'External runtime stylesheets should not be transformed: ' + context.resourceFile,
188+
);
189+
183190
// No transformation required if the resource is empty
184191
if (data.trim().length === 0) {
185192
return { content: '' };
@@ -194,6 +201,24 @@ export function createAngularCompilerHost(
194201
return typeof result === 'string' ? { content: result } : null;
195202
};
196203

204+
host.resourceNameToFileName = function (resourceName, containingFile) {
205+
const resolvedPath = nodePath.join(nodePath.dirname(containingFile), resourceName);
206+
207+
// All resource names that have HTML file extensions are assumed to be templates
208+
if (resourceName.endsWith('.html') || !hostOptions.externalStylesheets) {
209+
return resolvedPath;
210+
}
211+
212+
// For external stylesheets, create a unique identifier and store the mapping
213+
let externalId = hostOptions.externalStylesheets.get(resolvedPath);
214+
if (externalId === undefined) {
215+
externalId = createHash('sha256').update(resolvedPath).digest('hex');
216+
hostOptions.externalStylesheets.set(resolvedPath, externalId);
217+
}
218+
219+
return externalId + '.css';
220+
};
221+
197222
// Allow the AOT compiler to request the set of changed templates and styles
198223
host.getModifiedResourceFiles = function () {
199224
return hostOptions.modifiedFiles;

packages/angular/build/src/tools/angular/compilation/angular-compilation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export abstract class AngularCompilation {
7575
affectedFiles: ReadonlySet<ts.SourceFile>;
7676
compilerOptions: ng.CompilerOptions;
7777
referencedFiles: readonly string[];
78+
externalStylesheets?: ReadonlyMap<string, string>;
7879
}>;
7980

8081
abstract emitAffectedFiles(): Iterable<EmitFileResult> | Promise<Iterable<EmitFileResult>>;

packages/angular/build/src/tools/angular/compilation/aot-compilation.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class AotCompilation extends AngularCompilation {
4646
affectedFiles: ReadonlySet<ts.SourceFile>;
4747
compilerOptions: ng.CompilerOptions;
4848
referencedFiles: readonly string[];
49+
externalStylesheets?: ReadonlyMap<string, string>;
4950
}> {
5051
// Dynamically load the Angular compiler CLI package
5152
const { NgtscProgram, OptimizeFor } = await AngularCompilation.loadCompilerCli();
@@ -59,6 +60,10 @@ export class AotCompilation extends AngularCompilation {
5960
const compilerOptions =
6061
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;
6162

63+
if (compilerOptions.externalRuntimeStyles) {
64+
hostOptions.externalStylesheets ??= new Map();
65+
}
66+
6267
// Create Angular compiler host
6368
const host = createAngularCompilerHost(ts, compilerOptions, hostOptions);
6469

@@ -121,7 +126,12 @@ export class AotCompilation extends AngularCompilation {
121126
this.#state?.diagnosticCache,
122127
);
123128

124-
return { affectedFiles, compilerOptions, referencedFiles };
129+
return {
130+
affectedFiles,
131+
compilerOptions,
132+
referencedFiles,
133+
externalStylesheets: hostOptions.externalStylesheets,
134+
};
125135
}
126136

127137
*collectDiagnostics(modes: DiagnosticModes): Iterable<ts.Diagnostic> {

packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class ParallelCompilation extends AngularCompilation {
5454
affectedFiles: ReadonlySet<SourceFile>;
5555
compilerOptions: CompilerOptions;
5656
referencedFiles: readonly string[];
57+
externalStylesheets?: ReadonlyMap<string, string>;
5758
}> {
5859
const stylesheetChannel = new MessageChannel();
5960
// The request identifier is required because Angular can issue multiple concurrent requests

packages/angular/build/src/tools/angular/compilation/parallel-worker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export async function initialize(request: InitRequest) {
4242
}
4343
});
4444

45-
const { compilerOptions, referencedFiles } = await compilation.initialize(
45+
const { compilerOptions, referencedFiles, externalStylesheets } = await compilation.initialize(
4646
request.tsconfig,
4747
{
4848
fileReplacements: request.fileReplacements,
@@ -93,6 +93,7 @@ export async function initialize(request: InitRequest) {
9393
);
9494

9595
return {
96+
externalStylesheets,
9697
referencedFiles,
9798
// TODO: Expand? `allowJs`, `isolatedModules`, `sourceMap`, `inlineSourceMap` are the only fields needed currently.
9899
compilerOptions: {

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface CompilerPluginOptions {
4747
sourceFileCache?: SourceFileCache;
4848
loadResultCache?: LoadResultCache;
4949
incremental: boolean;
50+
externalRuntimeStyles?: boolean;
5051
}
5152

5253
// eslint-disable-next-line max-lines-per-function
@@ -151,6 +152,7 @@ export function createCompilerPlugin(
151152
// Angular compiler which does not have direct knowledge of transitive resource
152153
// dependencies or web worker processing.
153154
let modifiedFiles;
155+
let invalidatedStylesheetEntries;
154156
if (
155157
pluginOptions.sourceFileCache?.modifiedFiles.size &&
156158
referencedFileTracker &&
@@ -159,7 +161,7 @@ export function createCompilerPlugin(
159161
// TODO: Differentiate between changed input files and stale output files
160162
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
161163
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
162-
stylesheetBundler.invalidate(modifiedFiles);
164+
invalidatedStylesheetEntries = stylesheetBundler.invalidate(modifiedFiles);
163165
}
164166

165167
if (
@@ -265,6 +267,7 @@ export function createCompilerPlugin(
265267
// Initialize the Angular compilation for the current build.
266268
// In watch mode, previous build state will be reused.
267269
let referencedFiles;
270+
let externalStylesheets;
268271
try {
269272
const initializationResult = await compilation.initialize(
270273
pluginOptions.tsconfig,
@@ -279,6 +282,7 @@ export function createCompilerPlugin(
279282
!!initializationResult.compilerOptions.sourceMap ||
280283
!!initializationResult.compilerOptions.inlineSourceMap;
281284
referencedFiles = initializationResult.referencedFiles;
285+
externalStylesheets = initializationResult.externalStylesheets;
282286
} catch (error) {
283287
(result.errors ??= []).push({
284288
text: 'Angular compilation initialization failed.',
@@ -303,6 +307,40 @@ export function createCompilerPlugin(
303307
return result;
304308
}
305309

310+
if (externalStylesheets) {
311+
// Process any new external stylesheets
312+
for (const [stylesheetFile, externalId] of externalStylesheets) {
313+
const { outputFiles, metafile, errors, warnings } = await stylesheetBundler.bundleFile(
314+
stylesheetFile,
315+
externalId,
316+
);
317+
if (errors) {
318+
(result.errors ??= []).push(...errors);
319+
}
320+
(result.warnings ??= []).push(...warnings);
321+
additionalResults.set(stylesheetFile, {
322+
outputFiles,
323+
metafile,
324+
});
325+
}
326+
// Process any updated stylesheets
327+
if (invalidatedStylesheetEntries) {
328+
for (const stylesheetFile of invalidatedStylesheetEntries) {
329+
// externalId is already linked in the bundler context so only enabling is required here
330+
const { outputFiles, metafile, errors, warnings } =
331+
await stylesheetBundler.bundleFile(stylesheetFile, true);
332+
if (errors) {
333+
(result.errors ??= []).push(...errors);
334+
}
335+
(result.warnings ??= []).push(...warnings);
336+
additionalResults.set(stylesheetFile, {
337+
outputFiles,
338+
metafile,
339+
});
340+
}
341+
}
342+
}
343+
306344
// Update TypeScript file output cache for all affected files
307345
try {
308346
await profileAsync('NG_EMIT_TS', async () => {
@@ -571,6 +609,7 @@ function createCompilerOptionsTransformer(
571609
mapRoot: undefined,
572610
sourceRoot: undefined,
573611
preserveSymlinks,
612+
externalRuntimeStyles: pluginOptions.externalRuntimeStyles,
574613
};
575614
};
576615
}

packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts

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

99
import { OutputFile } from 'esbuild';
10+
import assert from 'node:assert';
1011
import { createHash } from 'node:crypto';
1112
import path from 'node:path';
1213
import { BuildOutputFileType, BundleContextResult, BundlerContext } from '../bundler-context';
@@ -35,17 +36,31 @@ export class ComponentStylesheetBundler {
3536
private readonly incremental: boolean,
3637
) {}
3738

38-
async bundleFile(entry: string) {
39+
async bundleFile(entry: string, externalId?: string | boolean) {
3940
const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => {
4041
return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => {
4142
const buildOptions = createStylesheetBundleOptions(this.options, loadCache);
42-
buildOptions.entryPoints = [entry];
43+
if (externalId) {
44+
assert(
45+
typeof externalId === 'string',
46+
'Initial external component stylesheets must have a string identifier',
47+
);
48+
49+
buildOptions.entryPoints = { [externalId]: entry };
50+
delete buildOptions.publicPath;
51+
} else {
52+
buildOptions.entryPoints = [entry];
53+
}
4354

4455
return buildOptions;
4556
});
4657
});
4758

48-
return this.extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
59+
return this.extractResult(
60+
await bundlerContext.bundle(),
61+
bundlerContext.watchFiles,
62+
!!externalId,
63+
);
4964
}
5065

5166
async bundleInline(data: string, filename: string, language: string) {
@@ -91,22 +106,33 @@ export class ComponentStylesheetBundler {
91106
});
92107

93108
// Extract the result of the bundling from the output files
94-
return this.extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles);
109+
return this.extractResult(await bundlerContext.bundle(), bundlerContext.watchFiles, false);
95110
}
96111

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

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

104-
for (const bundler of this.#fileContexts.values()) {
105-
bundler.invalidate(normalizedFiles);
125+
for (const [entry, bundler] of this.#fileContexts.entries()) {
126+
if (bundler.invalidate(normalizedFiles)) {
127+
entries ??= [];
128+
entries.push(entry);
129+
}
106130
}
107131
for (const bundler of this.#inlineContexts.values()) {
108132
bundler.invalidate(normalizedFiles);
109133
}
134+
135+
return entries;
110136
}
111137

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

120-
private extractResult(result: BundleContextResult, referencedFiles?: Set<string>) {
146+
private extractResult(
147+
result: BundleContextResult,
148+
referencedFiles: Set<string> | undefined,
149+
external: boolean,
150+
) {
121151
let contents = '';
122152
let metafile;
123153
const outputFiles: OutputFile[] = [];
@@ -140,7 +170,14 @@ export class ComponentStylesheetBundler {
140170

141171
outputFiles.push(clonedOutputFile);
142172
} else if (filename.endsWith('.css')) {
143-
contents = outputFile.text;
173+
if (external) {
174+
const clonedOutputFile = outputFile.clone();
175+
clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path);
176+
outputFiles.push(clonedOutputFile);
177+
contents = path.posix.join(this.options.publicPath ?? '', filename);
178+
} else {
179+
contents = outputFile.text;
180+
}
144181
} else {
145182
throw new Error(
146183
`Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`,

packages/angular/build/src/tools/esbuild/cache.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,12 @@ export class MemoryCache<V> extends Cache<V, Map<string, V>> {
126126
values() {
127127
return this.store.values();
128128
}
129+
130+
/**
131+
* Provides all the keys/values currently present in the cache instance.
132+
* @returns An iterable of all key/value pairs in the cache.
133+
*/
134+
entries() {
135+
return this.store.entries();
136+
}
129137
}

0 commit comments

Comments
 (0)