diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index b3f2c0ddcba8..730a3bffff18 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -90,6 +90,14 @@ interface InternalOptions { * @default false */ disableFullServerManifestGeneration?: 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. */ @@ -375,6 +383,7 @@ export async function normalizeOptions( clearScreen, define, disableFullServerManifestGeneration = false, + externalRuntimeStyles, } = options; // Return all the normalized options @@ -436,6 +445,7 @@ export async function normalizeOptions( clearScreen, define, disableFullServerManifestGeneration, + externalRuntimeStyles, }; } diff --git a/packages/angular/build/src/builders/dev-server/vite-server.ts b/packages/angular/build/src/builders/dev-server/vite-server.ts index 36667323251b..0fe72f970e81 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -18,6 +18,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 { @@ -130,6 +131,9 @@ export async function* serveWithVite( process.setSourceMapsEnabled(true); } + // TODO: Enable by default once full support across CLI and FW is integrated + browserOptions.externalRuntimeStyles = useComponentStyleHmr; + // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new JavaScriptTransformer( // Always enable JIT linking to support applications built with and without AOT. diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts index beb004a9b274..cb7616424f03 100644 --- a/packages/angular/build/src/tools/angular/angular-host.ts +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -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'; @@ -18,10 +19,12 @@ export interface AngularHostOptions { fileReplacements?: Record; sourceFileCache?: Map; modifiedFiles?: Set; + externalStylesheets?: Map; transformStylesheet( data: string, containingFile: string, stylesheetFile?: string, + order?: number, ): Promise; processWebWorker(workerFile: string, containingFile: string): string; } @@ -180,6 +183,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: '' }; @@ -189,11 +197,32 @@ export function createAngularCompilerHost( data, context.containingFile, context.resourceFile ?? undefined, + // TODO: Remove once available in compiler-cli types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (context as any).order, ); 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; diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts index 4bb9acf18eeb..4cb4852e54f7 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -75,6 +75,7 @@ export abstract class AngularCompilation { affectedFiles: ReadonlySet; compilerOptions: ng.CompilerOptions; referencedFiles: readonly string[]; + externalStylesheets?: ReadonlyMap; }>; abstract emitAffectedFiles(): Iterable | Promise>; diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts index 59de92fb76b1..9e566803fb58 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -46,6 +46,7 @@ export class AotCompilation extends AngularCompilation { affectedFiles: ReadonlySet; compilerOptions: ng.CompilerOptions; referencedFiles: readonly string[]; + externalStylesheets?: ReadonlyMap; }> { // Dynamically load the Angular compiler CLI package const { NgtscProgram, OptimizeFor } = await AngularCompilation.loadCompilerCli(); @@ -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); @@ -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 { diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts index 434ca958b4d1..817c4081ee21 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -47,6 +47,7 @@ export class ParallelCompilation extends AngularCompilation { affectedFiles: ReadonlySet; compilerOptions: CompilerOptions; referencedFiles: readonly string[]; + externalStylesheets?: ReadonlyMap; }> { const stylesheetChannel = new MessageChannel(); // The request identifier is required because Angular can issue multiple concurrent requests diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts index 21cdea54c940..38014bc670f9 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -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, @@ -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: { diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 344be6fdf838..8d2dd2837530 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -16,6 +16,7 @@ import type { PluginBuild, } from 'esbuild'; import assert from 'node:assert'; +import { createHash } from 'node:crypto'; import * as path from 'node:path'; import { maxWorkers, useTypeChecking } from '../../../utils/environment-options'; import { AngularHostOptions } from '../../angular/angular-host'; @@ -48,6 +49,7 @@ export interface CompilerPluginOptions { sourceFileCache?: SourceFileCache; loadResultCache?: LoadResultCache; incremental: boolean; + externalRuntimeStyles?: boolean; } // eslint-disable-next-line max-lines-per-function @@ -152,6 +154,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 && @@ -160,7 +163,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 ( @@ -176,7 +179,7 @@ export function createCompilerPlugin( fileReplacements: pluginOptions.fileReplacements, modifiedFiles, sourceFileCache: pluginOptions.sourceFileCache, - async transformStylesheet(data, containingFile, stylesheetFile) { + async transformStylesheet(data, containingFile, stylesheetFile, order) { let stylesheetResult; // Stylesheet file only exists for external stylesheets @@ -188,6 +191,16 @@ export function createCompilerPlugin( containingFile, // Inline stylesheets from a template style element are always CSS containingFile.endsWith('.html') ? 'css' : styleOptions.inlineStyleLanguage, + // When external runtime styles are enabled, an identifier for the style that does not change + // based on the content is required to avoid emitted JS code changes. Any JS code changes will + // invalid the output and force a full page reload for HMR cases. The containing file and order + // of the style within the containing file is used. + pluginOptions.externalRuntimeStyles + ? createHash('sha-256') + .update(containingFile) + .update((order ?? 0).toString()) + .digest('hex') + : undefined, ); } @@ -266,6 +279,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, @@ -280,6 +294,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.', @@ -304,6 +319,32 @@ export function createCompilerPlugin( return result; } + if (externalStylesheets) { + // Process any new external stylesheets + for (const [stylesheetFile, externalId] of externalStylesheets) { + await bundleExternalStylesheet( + stylesheetBundler, + stylesheetFile, + externalId, + result, + additionalResults, + ); + } + // 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 + await bundleExternalStylesheet( + stylesheetBundler, + stylesheetFile, + true, + result, + additionalResults, + ); + } + } + } + // Update TypeScript file output cache for all affected files try { await profileAsync('NG_EMIT_TS', async () => { @@ -500,6 +541,30 @@ export function createCompilerPlugin( }; } +async function bundleExternalStylesheet( + stylesheetBundler: ComponentStylesheetBundler, + stylesheetFile: string, + externalId: string | boolean, + result: OnStartResult, + additionalResults: Map< + string, + { outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] } + >, +) { + 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, + }); +} + function createCompilerOptionsTransformer( setupWarnings: PartialMessage[] | undefined, pluginOptions: CompilerPluginOptions, @@ -572,6 +637,7 @@ function createCompilerOptionsTransformer( mapRoot: undefined, sourceRoot: undefined, preserveSymlinks, + externalRuntimeStyles: pluginOptions.externalRuntimeStyles, }; }; } diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index a4f9143065a5..97c1b18ab5e1 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -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'; @@ -35,24 +36,41 @@ 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) { + async bundleInline(data: string, filename: string, language: string, externalId?: 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 id = createHash('sha256') + .update(data) + .update(externalId ?? '') + .digest('hex'); const entry = [language, id, filename].join(';'); const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { @@ -62,7 +80,13 @@ export class ComponentStylesheetBundler { const buildOptions = createStylesheetBundleOptions(this.options, loadCache, { [entry]: data, }); - buildOptions.entryPoints = [`${namespace};${entry}`]; + if (externalId) { + buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [`${namespace};${entry}`]; + } + buildOptions.plugins.push({ name: 'angular-component-styles', setup(build) { @@ -91,22 +115,37 @@ 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, + !!externalId, + ); } - invalidate(files: Iterable) { + /** + * 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[] | 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 { @@ -117,7 +156,11 @@ export class ComponentStylesheetBundler { await Promise.allSettled(contexts.map((context) => context.dispose())); } - private extractResult(result: BundleContextResult, referencedFiles?: Set) { + private extractResult( + result: BundleContextResult, + referencedFiles: Set | undefined, + external: boolean, + ) { let contents = ''; let metafile; const outputFiles: OutputFile[] = []; @@ -140,7 +183,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.`, diff --git a/packages/angular/build/src/tools/esbuild/cache.ts b/packages/angular/build/src/tools/esbuild/cache.ts index 5b37a6ab0c36..5bd0dc84d73f 100644 --- a/packages/angular/build/src/tools/esbuild/cache.ts +++ b/packages/angular/build/src/tools/esbuild/cache.ts @@ -126,4 +126,12 @@ export class MemoryCache extends Cache> { 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(); + } } diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts index 37d9721064ea..4361d7f0cca7 100644 --- a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -37,6 +37,7 @@ export function createCompilerPluginOptions( tailwindConfiguration, postcssConfiguration, publicPath, + externalRuntimeStyles, } = options; return { @@ -51,6 +52,7 @@ export function createCompilerPluginOptions( sourceFileCache, loadResultCache: sourceFileCache?.loadResultCache, incremental: !!options.watch, + externalRuntimeStyles, }, // Component stylesheet options styleOptions: { diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index cad94ce76129..dd47453cb974 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -100,3 +100,7 @@ export const useJSONBuildLogs = const optimizeChunksVariable = process.env['NG_BUILD_OPTIMIZE_CHUNKS']; export const shouldOptimizeChunks = isPresent(optimizeChunksVariable) && isEnabled(optimizeChunksVariable); + +const hmrComponentStylesVariable = process.env['NG_HMR_CSTYLES']; +export const useComponentStyleHmr = + isPresent(hmrComponentStylesVariable) && isEnabled(hmrComponentStylesVariable);