Skip to content

Commit a374690

Browse files
committed
feat(@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.
1 parent 26c6d2d commit a374690

File tree

6 files changed

+76
-4
lines changed

6 files changed

+76
-4
lines changed

packages/angular/build/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ ts_library(
5959
"//packages/angular_devkit/architect",
6060
"@npm//@ampproject/remapping",
6161
"@npm//@angular/common",
62+
"@npm//@angular/compiler",
6263
"@npm//@angular/compiler-cli",
6364
"@npm//@angular/core",
6465
"@npm//@angular/localize",

packages/angular/build/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"watchpack": "2.4.2"
4646
},
4747
"peerDependencies": {
48+
"@angular/compiler": "^19.0.0-next.0",
4849
"@angular/compiler-cli": "^19.0.0-next.0",
4950
"@angular/localize": "^19.0.0-next.0",
5051
"@angular/platform-server": "^19.0.0-next.0",

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export async function* serveWithVite(
145145
implicitServer: [],
146146
explicit: [],
147147
};
148+
const usedComponentStyles = new Map<string, string[]>();
148149

149150
// Add cleanup logic via a builder teardown.
150151
let deferred: () => void;
@@ -262,7 +263,14 @@ export async function* serveWithVite(
262263
// This is a workaround for: https://github.com/vitejs/vite/issues/14896
263264
await server.restart();
264265
} else {
265-
await handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger);
266+
await handleUpdate(
267+
normalizePath,
268+
generatedFiles,
269+
server,
270+
serverOptions,
271+
context.logger,
272+
usedComponentStyles,
273+
);
266274
}
267275
} else {
268276
const projectName = context.target?.project;
@@ -302,6 +310,7 @@ export async function* serveWithVite(
302310
prebundleTransformer,
303311
target,
304312
isZonelessApp(polyfills),
313+
usedComponentStyles,
305314
browserOptions.loader as EsbuildLoaderOption | undefined,
306315
extensions?.middleware,
307316
transformers?.indexHtml,
@@ -359,6 +368,7 @@ async function handleUpdate(
359368
server: ViteDevServer,
360369
serverOptions: NormalizedDevServerOptions,
361370
logger: BuilderContext['logger'],
371+
usedComponentStyles: Map<string, string[]>,
362372
): Promise<void> {
363373
const updatedFiles: string[] = [];
364374
let isServerFileUpdated = false;
@@ -394,7 +404,17 @@ async function handleUpdate(
394404
const timestamp = Date.now();
395405
server.hot.send({
396406
type: 'update',
397-
updates: updatedFiles.map((filePath) => {
407+
updates: updatedFiles.flatMap((filePath) => {
408+
const componentIds = usedComponentStyles.get(filePath);
409+
if (componentIds) {
410+
return componentIds.map((id) => ({
411+
type: 'css-update',
412+
timestamp,
413+
path: `${filePath}?component` + (id ? `=${id}` : ''),
414+
acceptedPath: filePath,
415+
}));
416+
}
417+
398418
return {
399419
type: 'css-update',
400420
timestamp,
@@ -499,6 +519,7 @@ export async function setupServer(
499519
prebundleTransformer: JavaScriptTransformer,
500520
target: string[],
501521
zoneless: boolean,
522+
usedComponentStyles: Map<string, string[]>,
502523
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
503524
extensionMiddleware?: Connect.NextHandleFunction[],
504525
indexHtmlTransformer?: (content: string) => Promise<string>,
@@ -607,6 +628,7 @@ export async function setupServer(
607628
indexHtmlTransformer,
608629
extensionMiddleware,
609630
normalizePath,
631+
usedComponentStyles,
610632
}),
611633
createRemoveIdPrefixPlugin(externalMetadata.explicit),
612634
],

packages/angular/build/src/tools/vite/angular-memory-plugin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface AngularMemoryPluginOptions {
2929
extensionMiddleware?: Connect.NextHandleFunction[];
3030
indexHtmlTransformer?: (content: string) => Promise<string>;
3131
normalizePath: (path: string) => string;
32+
usedComponentStyles: Map<string, string[]>;
3233
}
3334

3435
export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): Plugin {
@@ -42,6 +43,7 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
4243
extensionMiddleware,
4344
indexHtmlTransformer,
4445
normalizePath,
46+
usedComponentStyles,
4547
} = options;
4648

4749
return {
@@ -113,7 +115,9 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
113115
};
114116

115117
// Assets and resources get handled first
116-
server.middlewares.use(createAngularAssetsMiddleware(server, assets, outputFiles));
118+
server.middlewares.use(
119+
createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles),
120+
);
117121

118122
if (extensionMiddleware?.length) {
119123
extensionMiddleware.forEach((middleware) => server.middlewares.use(middleware));

packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@
99
import { lookup as lookupMimeType } from 'mrmime';
1010
import { extname } from 'node:path';
1111
import type { Connect, ViteDevServer } from 'vite';
12+
import { loadEsmModule } from '../../../utils/load-esm';
1213
import {
1314
AngularMemoryOutputFiles,
1415
appendServerConfiguredHeaders,
1516
pathnameWithoutBasePath,
1617
} from '../utils';
1718

19+
const COMPONENT_REGEX = /%COMP%/g;
20+
1821
export function createAngularAssetsMiddleware(
1922
server: ViteDevServer,
2023
assets: Map<string, string>,
2124
outputFiles: AngularMemoryOutputFiles,
25+
usedComponentStyles: Map<string, string[]>,
2226
): Connect.NextHandleFunction {
2327
return function (req, res, next) {
2428
if (req.url === undefined || res.writableEnded) {
@@ -69,13 +73,52 @@ export function createAngularAssetsMiddleware(
6973
if (extension !== '.js' && extension !== '.html') {
7074
const outputFile = outputFiles.get(pathname);
7175
if (outputFile?.servable) {
76+
const data = outputFile.contents;
77+
if (extension === '.css') {
78+
// Inject component ID for view encapsulation if requested
79+
const componentId = new URL(req.url, 'http://localhost').searchParams.get('component');
80+
if (componentId !== null) {
81+
// Record the component style usage for HMR updates
82+
const usedIds = usedComponentStyles.get(pathname);
83+
if (usedIds === undefined) {
84+
usedComponentStyles.set(pathname, [componentId]);
85+
} else {
86+
usedIds.push(componentId);
87+
}
88+
// Shim the stylesheet if a component ID is provided
89+
if (componentId.length > 0) {
90+
// Validate component ID
91+
// TODO: Determine if this regex is too strict for the APP_ID segment
92+
if (/[.-A-Za-z0-9]+-c\d{9}$/.test(componentId)) {
93+
loadEsmModule<typeof import('@angular/compiler')>('@angular/compiler')
94+
.then((compilerModule) => {
95+
const encapsulatedData = compilerModule
96+
.encapsulateStyle(new TextDecoder().decode(data))
97+
.replaceAll(COMPONENT_REGEX, componentId);
98+
99+
res.setHeader('Content-Type', 'text/css');
100+
res.setHeader('Cache-Control', 'no-cache');
101+
appendServerConfiguredHeaders(server, res);
102+
res.end(encapsulatedData);
103+
})
104+
.catch((e) => next(e));
105+
106+
return;
107+
} else {
108+
// eslint-disable-next-line no-console
109+
console.error('Invalid component stylesheet ID request: ' + componentId);
110+
}
111+
}
112+
}
113+
}
114+
72115
const mimeType = lookupMimeType(extension);
73116
if (mimeType) {
74117
res.setHeader('Content-Type', mimeType);
75118
}
76119
res.setHeader('Cache-Control', 'no-cache');
77120
appendServerConfiguredHeaders(server, res);
78-
res.end(outputFile.contents);
121+
res.end(data);
79122

80123
return;
81124
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ __metadata:
407407
vite: "npm:5.4.3"
408408
watchpack: "npm:2.4.2"
409409
peerDependencies:
410+
"@angular/compiler": ^19.0.0-next.0
410411
"@angular/compiler-cli": ^19.0.0-next.0
411412
"@angular/localize": ^19.0.0-next.0
412413
"@angular/platform-server": ^19.0.0-next.0

0 commit comments

Comments
 (0)