From 9f7e2c42c75994452d0223140f1b7f14803dd8cf Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:34:58 -0400 Subject: [PATCH] refactor(@angular/build): support dev server direct component style serving The Vite-based development server now provides support for serving individual component stylesheets both with and without emulated view encapsulation. This capability is not yet used by the Angular runtime code. The ability to use external stylesheets instead of bundling the style content is an enabling capability primarily for automatic component style HMR features. Additionally, it has potential future benefits for development mode deferred style processing which may reduce the initial build time when using the development server. The application build itself also does not yet generate external stylesheets. --- packages/angular/build/BUILD.bazel | 1 + packages/angular/build/package.json | 1 + .../src/builders/dev-server/vite-server.ts | 31 ++++++++++++- .../src/tools/vite/angular-memory-plugin.ts | 6 ++- .../vite/middlewares/assets-middleware.ts | 44 ++++++++++++++++++- yarn.lock | 1 + 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index b60ab7d83299..9ff12a1b770a 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -59,6 +59,7 @@ ts_library( "//packages/angular_devkit/architect", "@npm//@ampproject/remapping", "@npm//@angular/common", + "@npm//@angular/compiler", "@npm//@angular/compiler-cli", "@npm//@angular/core", "@npm//@angular/localize", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index a2e7d6e527dc..0d1d116dbe1d 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -45,6 +45,7 @@ "watchpack": "2.4.2" }, "peerDependencies": { + "@angular/compiler": "^19.0.0-next.0", "@angular/compiler-cli": "^19.0.0-next.0", "@angular/localize": "^19.0.0-next.0", "@angular/platform-server": "^19.0.0-next.0", 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 edf056e8e5e5..79ba9fb5abe5 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -145,6 +145,7 @@ export async function* serveWithVite( implicitServer: [], explicit: [], }; + const usedComponentStyles = new Map(); // Add cleanup logic via a builder teardown. let deferred: () => void; @@ -262,7 +263,14 @@ export async function* serveWithVite( // This is a workaround for: https://github.com/vitejs/vite/issues/14896 await server.restart(); } else { - await handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger); + await handleUpdate( + normalizePath, + generatedFiles, + server, + serverOptions, + context.logger, + usedComponentStyles, + ); } } else { const projectName = context.target?.project; @@ -302,6 +310,7 @@ export async function* serveWithVite( prebundleTransformer, target, isZonelessApp(polyfills), + usedComponentStyles, browserOptions.loader as EsbuildLoaderOption | undefined, extensions?.middleware, transformers?.indexHtml, @@ -359,6 +368,7 @@ async function handleUpdate( server: ViteDevServer, serverOptions: NormalizedDevServerOptions, logger: BuilderContext['logger'], + usedComponentStyles: Map, ): Promise { const updatedFiles: string[] = []; let isServerFileUpdated = false; @@ -394,7 +404,22 @@ async function handleUpdate( const timestamp = Date.now(); server.hot.send({ type: 'update', - updates: updatedFiles.map((filePath) => { + updates: updatedFiles.flatMap((filePath) => { + // For component styles, an HMR update must be sent for each one with the corresponding + // component identifier search parameter (`ngcomp`). The Vite client code will not keep + // 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 componentIds.map((id) => ({ + type: 'css-update', + timestamp, + path: `${filePath}?ngcomp` + (id ? `=${id}` : ''), + acceptedPath: filePath, + })); + } + return { type: 'css-update', timestamp, @@ -499,6 +524,7 @@ export async function setupServer( prebundleTransformer: JavaScriptTransformer, target: string[], zoneless: boolean, + usedComponentStyles: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, extensionMiddleware?: Connect.NextHandleFunction[], indexHtmlTransformer?: (content: string) => Promise, @@ -607,6 +633,7 @@ export async function setupServer( indexHtmlTransformer, extensionMiddleware, normalizePath, + usedComponentStyles, }), createRemoveIdPrefixPlugin(externalMetadata.explicit), ], diff --git a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts index d53410918b9c..4a39ba50417d 100644 --- a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts @@ -29,6 +29,7 @@ export interface AngularMemoryPluginOptions { extensionMiddleware?: Connect.NextHandleFunction[]; indexHtmlTransformer?: (content: string) => Promise; normalizePath: (path: string) => string; + usedComponentStyles: Map; } export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): Plugin { @@ -42,6 +43,7 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): extensionMiddleware, indexHtmlTransformer, normalizePath, + usedComponentStyles, } = options; return { @@ -113,7 +115,9 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): }; // Assets and resources get handled first - server.middlewares.use(createAngularAssetsMiddleware(server, assets, outputFiles)); + server.middlewares.use( + createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles), + ); if (extensionMiddleware?.length) { extensionMiddleware.forEach((middleware) => server.middlewares.use(middleware)); 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 9dd93e1df516..8c2647949165 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -9,16 +9,20 @@ import { lookup as lookupMimeType } from 'mrmime'; import { extname } from 'node:path'; import type { Connect, ViteDevServer } from 'vite'; +import { loadEsmModule } from '../../../utils/load-esm'; import { AngularMemoryOutputFiles, appendServerConfiguredHeaders, pathnameWithoutBasePath, } from '../utils'; +const COMPONENT_REGEX = /%COMP%/g; + export function createAngularAssetsMiddleware( server: ViteDevServer, assets: Map, outputFiles: AngularMemoryOutputFiles, + usedComponentStyles: Map, ): Connect.NextHandleFunction { return function (req, res, next) { if (req.url === undefined || res.writableEnded) { @@ -69,13 +73,51 @@ export function createAngularAssetsMiddleware( if (extension !== '.js' && extension !== '.html') { const outputFile = outputFiles.get(pathname); if (outputFile?.servable) { + const data = outputFile.contents; + if (extension === '.css') { + // Inject component ID for view encapsulation if requested + const componentId = new URL(req.url, 'http://localhost').searchParams.get('ngcomp'); + if (componentId !== null) { + // Record the component style usage for HMR updates + const usedIds = usedComponentStyles.get(pathname); + if (usedIds === undefined) { + usedComponentStyles.set(pathname, [componentId]); + } else { + usedIds.push(componentId); + } + // Shim the stylesheet if a component ID is provided + if (componentId.length > 0) { + // Validate component ID + if (/[_.-A-Za-z0-9]+-c\d{9}$/.test(componentId)) { + loadEsmModule('@angular/compiler') + .then((compilerModule) => { + const encapsulatedData = compilerModule + .encapsulateStyle(new TextDecoder().decode(data)) + .replaceAll(COMPONENT_REGEX, componentId); + + res.setHeader('Content-Type', 'text/css'); + res.setHeader('Cache-Control', 'no-cache'); + appendServerConfiguredHeaders(server, res); + res.end(encapsulatedData); + }) + .catch((e) => next(e)); + + return; + } else { + // eslint-disable-next-line no-console + console.error('Invalid component stylesheet ID request: ' + componentId); + } + } + } + } + const mimeType = lookupMimeType(extension); if (mimeType) { res.setHeader('Content-Type', mimeType); } res.setHeader('Cache-Control', 'no-cache'); appendServerConfiguredHeaders(server, res); - res.end(outputFile.contents); + res.end(data); return; } diff --git a/yarn.lock b/yarn.lock index 03d317185ba0..d6efea66d444 100644 --- a/yarn.lock +++ b/yarn.lock @@ -407,6 +407,7 @@ __metadata: vite: "npm:5.4.3" watchpack: "npm:2.4.2" peerDependencies: + "@angular/compiler": ^19.0.0-next.0 "@angular/compiler-cli": ^19.0.0-next.0 "@angular/localize": ^19.0.0-next.0 "@angular/platform-server": ^19.0.0-next.0