Skip to content

Commit 9502d46

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 5da0e48 commit 9502d46

File tree

12 files changed

+170
-12
lines changed

12 files changed

+170
-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
@@ -90,6 +90,14 @@ interface InternalOptions {
9090
* @default false
9191
*/
9292
disableFullServerManifestGeneration?: boolean;
93+
94+
/**
95+
* Enables the use of AOT compiler emitted external runtime styles.
96+
* External runtime styles use `link` elements instead of embedded style content in the output JavaScript.
97+
* This option is only intended to be used with a development server that can process and serve component
98+
* styles.
99+
*/
100+
externalRuntimeStyles?: boolean;
93101
}
94102

95103
/** Full set of options for `application` builder. */
@@ -375,6 +383,7 @@ export async function normalizeOptions(
375383
clearScreen,
376384
define,
377385
disableFullServerManifestGeneration = false,
386+
externalRuntimeStyles,
378387
} = options;
379388

380389
// Return all the normalized options
@@ -436,6 +445,7 @@ export async function normalizeOptions(
436445
clearScreen,
437446
define,
438447
disableFullServerManifestGeneration,
448+
externalRuntimeStyles,
439449
};
440450
}
441451

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
createRemoveIdPrefixPlugin,
2424
} from '../../tools/vite/plugins';
2525
import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils';
26+
import { useComponentStyleHmr } from '../../utils/environment-options';
2627
import { loadEsmModule } from '../../utils/load-esm';
2728
import { Result, ResultFile, ResultKind } from '../application/results';
2829
import {
@@ -135,6 +136,9 @@ export async function* serveWithVite(
135136
process.setSourceMapsEnabled(true);
136137
}
137138

139+
// TODO: Enable by default once full support across CLI and FW is integrated
140+
browserOptions.externalRuntimeStyles = useComponentStyleHmr;
141+
138142
// Setup the prebundling transformer that will be shared across Vite prebundling requests
139143
const prebundleTransformer = new JavaScriptTransformer(
140144
// Always enable JIT linking to support applications built with and without AOT.

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
@@ -47,6 +47,7 @@ export class ParallelCompilation extends AngularCompilation {
4747
affectedFiles: ReadonlySet<SourceFile>;
4848
compilerOptions: CompilerOptions;
4949
referencedFiles: readonly string[];
50+
externalStylesheets?: ReadonlyMap<string, string>;
5051
}> {
5152
const stylesheetChannel = new MessageChannel();
5253
// 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: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface CompilerPluginOptions {
4848
sourceFileCache?: SourceFileCache;
4949
loadResultCache?: LoadResultCache;
5050
incremental: boolean;
51+
externalRuntimeStyles?: boolean;
5152
}
5253

5354
// eslint-disable-next-line max-lines-per-function
@@ -152,6 +153,7 @@ export function createCompilerPlugin(
152153
// Angular compiler which does not have direct knowledge of transitive resource
153154
// dependencies or web worker processing.
154155
let modifiedFiles;
156+
let invalidatedStylesheetEntries;
155157
if (
156158
pluginOptions.sourceFileCache?.modifiedFiles.size &&
157159
referencedFileTracker &&
@@ -160,7 +162,7 @@ export function createCompilerPlugin(
160162
// TODO: Differentiate between changed input files and stale output files
161163
modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles);
162164
pluginOptions.sourceFileCache.invalidate(modifiedFiles);
163-
stylesheetBundler.invalidate(modifiedFiles);
165+
invalidatedStylesheetEntries = stylesheetBundler.invalidate(modifiedFiles);
164166
}
165167

166168
if (
@@ -266,6 +268,7 @@ export function createCompilerPlugin(
266268
// Initialize the Angular compilation for the current build.
267269
// In watch mode, previous build state will be reused.
268270
let referencedFiles;
271+
let externalStylesheets;
269272
try {
270273
const initializationResult = await compilation.initialize(
271274
pluginOptions.tsconfig,
@@ -280,6 +283,7 @@ export function createCompilerPlugin(
280283
!!initializationResult.compilerOptions.sourceMap ||
281284
!!initializationResult.compilerOptions.inlineSourceMap;
282285
referencedFiles = initializationResult.referencedFiles;
286+
externalStylesheets = initializationResult.externalStylesheets;
283287
} catch (error) {
284288
(result.errors ??= []).push({
285289
text: 'Angular compilation initialization failed.',
@@ -304,6 +308,32 @@ export function createCompilerPlugin(
304308
return result;
305309
}
306310

311+
if (externalStylesheets) {
312+
// Process any new external stylesheets
313+
for (const [stylesheetFile, externalId] of externalStylesheets) {
314+
await bundleExternalStylesheet(
315+
stylesheetBundler,
316+
stylesheetFile,
317+
externalId,
318+
result,
319+
additionalResults,
320+
);
321+
}
322+
// Process any updated stylesheets
323+
if (invalidatedStylesheetEntries) {
324+
for (const stylesheetFile of invalidatedStylesheetEntries) {
325+
// externalId is already linked in the bundler context so only enabling is required here
326+
await bundleExternalStylesheet(
327+
stylesheetBundler,
328+
stylesheetFile,
329+
true,
330+
result,
331+
additionalResults,
332+
);
333+
}
334+
}
335+
}
336+
307337
// Update TypeScript file output cache for all affected files
308338
try {
309339
await profileAsync('NG_EMIT_TS', async () => {
@@ -500,6 +530,30 @@ export function createCompilerPlugin(
500530
};
501531
}
502532

533+
async function bundleExternalStylesheet(
534+
stylesheetBundler: ComponentStylesheetBundler,
535+
stylesheetFile: string,
536+
externalId: string | boolean,
537+
result: OnStartResult,
538+
additionalResults: Map<
539+
string,
540+
{ outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] }
541+
>,
542+
) {
543+
const { outputFiles, metafile, errors, warnings } = await stylesheetBundler.bundleFile(
544+
stylesheetFile,
545+
externalId,
546+
);
547+
if (errors) {
548+
(result.errors ??= []).push(...errors);
549+
}
550+
(result.warnings ??= []).push(...warnings);
551+
additionalResults.set(stylesheetFile, {
552+
outputFiles,
553+
metafile,
554+
});
555+
}
556+
503557
function createCompilerOptionsTransformer(
504558
setupWarnings: PartialMessage[] | undefined,
505559
pluginOptions: CompilerPluginOptions,
@@ -572,6 +626,7 @@ function createCompilerOptionsTransformer(
572626
mapRoot: undefined,
573627
sourceRoot: undefined,
574628
preserveSymlinks,
629+
externalRuntimeStyles: pluginOptions.externalRuntimeStyles,
575630
};
576631
};
577632
}

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.`,

0 commit comments

Comments
 (0)