From 602081d18f3c5e0a878f4bf0986461d8b6041130 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:13:53 -0500 Subject: [PATCH] refactor(@angular/build): use structured component stylesheet tracking for hot replacement When using the development server with the application builder, the internal state of any external component stylesheets is now more comprehensively tracked. This allows for more flexibility in both debugging potential problems as well as supporting additional stylesheet preprocessing steps including deferred component stylesheet processing. --- .../src/builders/dev-server/vite-server.ts | 59 +++++++++++++------ .../vite/middlewares/assets-middleware.ts | 27 ++++++--- .../build/src/tools/vite/middlewares/index.ts | 2 +- .../vite/plugins/setup-middlewares-plugin.ts | 7 ++- 4 files changed, 64 insertions(+), 31 deletions(-) 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 f8d0dfe8fba9..dfbbc8454b53 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -14,6 +14,7 @@ import { readFile } from 'node:fs/promises'; import { builtinModules, isBuiltin } from 'node:module'; import { join } from 'node:path'; import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from 'vite'; +import type { ComponentStyleRecord } from '../../tools/vite/middlewares'; import { ServerSsrMode, createAngularLocaleDataPlugin, @@ -175,7 +176,7 @@ export async function* serveWithVite( explicitBrowser: [], explicitServer: [], }; - const usedComponentStyles = new Map>(); + const componentStyles = new Map(); const templateUpdates = new Map(); // Add cleanup logic via a builder teardown. @@ -232,11 +233,17 @@ export async function* serveWithVite( assetFiles.set('/' + normalizePath(outputPath), normalizePath(file.inputPath)); } } - // Clear stale template updates on a code rebuilds + // Clear stale template updates on code rebuilds templateUpdates.clear(); // Analyze result files for changes - analyzeResultFiles(normalizePath, htmlIndexPath, result.files, generatedFiles); + analyzeResultFiles( + normalizePath, + htmlIndexPath, + result.files, + generatedFiles, + componentStyles, + ); break; case ResultKind.Incremental: assert(server, 'Builder must provide an initial full build before incremental results.'); @@ -321,7 +328,7 @@ export async function* serveWithVite( server, serverOptions, context.logger, - usedComponentStyles, + componentStyles, ); } } else { @@ -380,7 +387,7 @@ export async function* serveWithVite( prebundleTransformer, target, isZonelessApp(polyfills), - usedComponentStyles, + componentStyles, templateUpdates, browserOptions.loader as EsbuildLoaderOption | undefined, extensions?.middleware, @@ -406,7 +413,7 @@ export async function* serveWithVite( key: 'r', description: 'force reload browser', action(server) { - usedComponentStyles.clear(); + componentStyles.forEach((record) => record.used?.clear()); server.ws.send({ type: 'full-reload', path: '*', @@ -434,7 +441,7 @@ async function handleUpdate( server: ViteDevServer, serverOptions: NormalizedDevServerOptions, logger: BuilderContext['logger'], - usedComponentStyles: Map>, + componentStyles: Map, ): Promise { const updatedFiles: string[] = []; let destroyAngularServerAppCalled = false; @@ -478,15 +485,17 @@ async function handleUpdate( // the existing search parameters when it performs an update and each one must be // specified explicitly. Typically, there is only one each though as specific style files // are not typically reused across components. - const componentIds = usedComponentStyles.get(filePath); - if (componentIds) { - return Array.from(componentIds).map((id) => { - if (id === true) { - // Shadow DOM components currently require a full reload. - // Vite's CSS hot replacement does not support shadow root searching. - requiresReload = true; - } + const record = componentStyles.get(filePath); + if (record) { + if (record.reload) { + // Shadow DOM components currently require a full reload. + // Vite's CSS hot replacement does not support shadow root searching. + requiresReload = true; + + return []; + } + return Array.from(record.used ?? []).map((id) => { return { type: 'css-update' as const, timestamp, @@ -519,7 +528,7 @@ async function handleUpdate( // Send reload command to clients if (serverOptions.liveReload) { // Clear used component tracking on full reload - usedComponentStyles.clear(); + componentStyles.forEach((record) => record.used?.clear()); server.ws.send({ type: 'full-reload', @@ -535,6 +544,7 @@ function analyzeResultFiles( htmlIndexPath: string, resultFiles: Record, generatedFiles: Map, + componentStyles: Map, ) { const seen = new Set(['/index.html']); for (const [outputPath, file] of Object.entries(resultFiles)) { @@ -589,12 +599,25 @@ function analyzeResultFiles( type: file.type, servable, }); + + // Record any external component styles + if (filePath.endsWith('.css') && /^\/[a-f0-9]{64}\.css$/.test(filePath)) { + const componentStyle = componentStyles.get(filePath); + if (componentStyle) { + componentStyle.rawContent = file.contents; + } else { + componentStyles.set(filePath, { + rawContent: file.contents, + }); + } + } } // Clear stale output files for (const file of generatedFiles.keys()) { if (!seen.has(file)) { generatedFiles.delete(file); + componentStyles.delete(file); } } } @@ -609,7 +632,7 @@ export async function setupServer( prebundleTransformer: JavaScriptTransformer, target: string[], zoneless: boolean, - usedComponentStyles: Map>, + componentStyles: Map, templateUpdates: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, extensionMiddleware?: Connect.NextHandleFunction[], @@ -719,7 +742,7 @@ export async function setupServer( assets, indexHtmlTransformer, extensionMiddleware, - usedComponentStyles, + componentStyles, templateUpdates, ssrMode, }), diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts index 76a6f6c5359f..b712744e8f33 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -11,11 +11,17 @@ import { extname } from 'node:path'; import type { Connect, ViteDevServer } from 'vite'; import { AngularMemoryOutputFiles, pathnameWithoutBasePath } from '../utils'; +export interface ComponentStyleRecord { + rawContent: Uint8Array; + used?: Set; + reload?: boolean; +} + export function createAngularAssetsMiddleware( server: ViteDevServer, assets: Map, outputFiles: AngularMemoryOutputFiles, - usedComponentStyles: Map>, + componentStyles: Map, encapsulateStyle: (style: Uint8Array, componentId: string) => string, ): Connect.NextHandleFunction { return function angularAssetsMiddleware(req, res, next) { @@ -74,21 +80,24 @@ export function createAngularAssetsMiddleware( const outputFile = outputFiles.get(pathname); if (outputFile?.servable) { let data: Uint8Array | string = outputFile.contents; - if (extension === '.css') { + const componentStyle = componentStyles.get(pathname); + if (componentStyle) { // Inject component ID for view encapsulation if requested const searchParams = new URL(req.url, 'http://localhost').searchParams; const componentId = searchParams.get('ngcomp'); if (componentId !== null) { // Track if the component uses ShadowDOM encapsulation (3 = ViewEncapsulation.ShadowDom) - const shadow = searchParams.get('e') === '3'; + // Shadow DOM components currently require a full reload. + // Vite's CSS hot replacement does not support shadow root searching. + if (searchParams.get('e') === '3') { + componentStyle.reload = true; + } - // Record the component style usage for HMR updates (true = shadow; false = none; string = emulated) - const usedIds = usedComponentStyles.get(pathname); - const trackingId = componentId || shadow; - if (usedIds === undefined) { - usedComponentStyles.set(pathname, new Set([trackingId])); + // Record the component style usage for HMR updates + if (componentStyle.used === undefined) { + componentStyle.used = new Set([componentId]); } else { - usedIds.add(trackingId); + componentStyle.used.add(componentId); } // Report if there are no changes to avoid reprocessing diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts index fb5416c07e7e..2981e9912081 100644 --- a/packages/angular/build/src/tools/vite/middlewares/index.ts +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -export { createAngularAssetsMiddleware } from './assets-middleware'; +export { type ComponentStyleRecord, createAngularAssetsMiddleware } from './assets-middleware'; export { angularHtmlFallbackMiddleware } from './html-fallback-middleware'; export { createAngularIndexHtmlMiddleware } from './index-html-middleware'; export { diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts index 07983c011878..1118cf3ac797 100644 --- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -9,6 +9,7 @@ import type { Connect, Plugin } from 'vite'; import { loadEsmModule } from '../../../utils/load-esm'; import { + ComponentStyleRecord, angularHtmlFallbackMiddleware, createAngularAssetsMiddleware, createAngularComponentMiddleware, @@ -49,7 +50,7 @@ interface AngularSetupMiddlewaresPluginOptions { assets: Map; extensionMiddleware?: Connect.NextHandleFunction[]; indexHtmlTransformer?: (content: string) => Promise; - usedComponentStyles: Map>; + componentStyles: Map; templateUpdates: Map; ssrMode: ServerSsrMode; } @@ -78,7 +79,7 @@ export function createAngularSetupMiddlewaresPlugin( outputFiles, extensionMiddleware, assets, - usedComponentStyles, + componentStyles, templateUpdates, ssrMode, } = options; @@ -91,7 +92,7 @@ export function createAngularSetupMiddlewaresPlugin( server, assets, outputFiles, - usedComponentStyles, + componentStyles, await createEncapsulateStyle(), ), );