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