Skip to content

Commit 764708f

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 a7f466a commit 764708f

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
@@ -143,6 +143,7 @@ export async function* serveWithVite(
143143
implicitServer: [],
144144
explicit: [],
145145
};
146+
const usedComponentStyles = new Map<string, string[]>();
146147

147148
// Add cleanup logic via a builder teardown.
148149
let deferred: () => void;
@@ -255,7 +256,14 @@ export async function* serveWithVite(
255256
...new Set([...server.config.server.fs.allow, ...assetFiles.values()]),
256257
];
257258

258-
handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger);
259+
handleUpdate(
260+
normalizePath,
261+
generatedFiles,
262+
server,
263+
serverOptions,
264+
context.logger,
265+
usedComponentStyles,
266+
);
259267

260268
if (requiresServerRestart) {
261269
// Restart the server to force SSR dep re-optimization when a dependency has been added.
@@ -300,6 +308,7 @@ export async function* serveWithVite(
300308
prebundleTransformer,
301309
target,
302310
isZonelessApp(polyfills),
311+
usedComponentStyles,
303312
browserOptions.loader as EsbuildLoaderOption | undefined,
304313
extensions?.middleware,
305314
transformers?.indexHtml,
@@ -357,6 +366,7 @@ function handleUpdate(
357366
server: ViteDevServer,
358367
serverOptions: NormalizedDevServerOptions,
359368
logger: BuilderContext['logger'],
369+
usedComponentStyles: Map<string, string[]>,
360370
): void {
361371
const updatedFiles: string[] = [];
362372

@@ -380,7 +390,17 @@ function handleUpdate(
380390
const timestamp = Date.now();
381391
server.hot.send({
382392
type: 'update',
383-
updates: updatedFiles.map((filePath) => {
393+
updates: updatedFiles.flatMap((filePath) => {
394+
const componentIds = usedComponentStyles.get(filePath);
395+
if (componentIds) {
396+
return componentIds.map((id) => ({
397+
type: 'css-update',
398+
timestamp,
399+
path: `${filePath}?component` + (id ? `=${id}` : ''),
400+
acceptedPath: filePath,
401+
}));
402+
}
403+
384404
return {
385405
type: 'css-update',
386406
timestamp,
@@ -483,6 +503,7 @@ export async function setupServer(
483503
prebundleTransformer: JavaScriptTransformer,
484504
target: string[],
485505
zoneless: boolean,
506+
usedComponentStyles: Map<string, string[]>,
486507
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
487508
extensionMiddleware?: Connect.NextHandleFunction[],
488509
indexHtmlTransformer?: (content: string) => Promise<string>,
@@ -591,6 +612,7 @@ export async function setupServer(
591612
indexHtmlTransformer,
592613
extensionMiddleware,
593614
normalizePath,
615+
usedComponentStyles,
594616
}),
595617
createRemoveIdPrefixPlugin(externalMetadata.explicit),
596618
],

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.0"
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)