Skip to content

Commit 434979a

Browse files
clydinalan-agius4
authored andcommitted
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.
1 parent 5bba61d commit 434979a

File tree

6 files changed

+80
-4
lines changed

6 files changed

+80
-4
lines changed

Diff for: packages/angular/build/BUILD.bazel

+1
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",

Diff for: packages/angular/build/package.json

+1
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",

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

+29-2
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export async function* serveWithVite(
151151
explicitBrowser: [],
152152
explicitServer: [],
153153
};
154+
const usedComponentStyles = new Map<string, string[]>();
154155

155156
// Add cleanup logic via a builder teardown.
156157
let deferred: () => void;
@@ -271,7 +272,14 @@ export async function* serveWithVite(
271272
// This is a workaround for: https://github.com/vitejs/vite/issues/14896
272273
await server.restart();
273274
} else {
274-
await handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger);
275+
await handleUpdate(
276+
normalizePath,
277+
generatedFiles,
278+
server,
279+
serverOptions,
280+
context.logger,
281+
usedComponentStyles,
282+
);
275283
}
276284
} else {
277285
const projectName = context.target?.project;
@@ -311,6 +319,7 @@ export async function* serveWithVite(
311319
prebundleTransformer,
312320
target,
313321
isZonelessApp(polyfills),
322+
usedComponentStyles,
314323
browserOptions.loader as EsbuildLoaderOption | undefined,
315324
extensions?.middleware,
316325
transformers?.indexHtml,
@@ -368,6 +377,7 @@ async function handleUpdate(
368377
server: ViteDevServer,
369378
serverOptions: NormalizedDevServerOptions,
370379
logger: BuilderContext['logger'],
380+
usedComponentStyles: Map<string, string[]>,
371381
): Promise<void> {
372382
const updatedFiles: string[] = [];
373383
let isServerFileUpdated = false;
@@ -403,7 +413,22 @@ async function handleUpdate(
403413
const timestamp = Date.now();
404414
server.hot.send({
405415
type: 'update',
406-
updates: updatedFiles.map((filePath) => {
416+
updates: updatedFiles.flatMap((filePath) => {
417+
// For component styles, an HMR update must be sent for each one with the corresponding
418+
// component identifier search parameter (`ngcomp`). The Vite client code will not keep
419+
// the existing search parameters when it performs an update and each one must be
420+
// specified explicitly. Typically, there is only one each though as specific style files
421+
// are not typically reused across components.
422+
const componentIds = usedComponentStyles.get(filePath);
423+
if (componentIds) {
424+
return componentIds.map((id) => ({
425+
type: 'css-update',
426+
timestamp,
427+
path: `${filePath}?ngcomp` + (id ? `=${id}` : ''),
428+
acceptedPath: filePath,
429+
}));
430+
}
431+
407432
return {
408433
type: 'css-update',
409434
timestamp,
@@ -508,6 +533,7 @@ export async function setupServer(
508533
prebundleTransformer: JavaScriptTransformer,
509534
target: string[],
510535
zoneless: boolean,
536+
usedComponentStyles: Map<string, string[]>,
511537
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
512538
extensionMiddleware?: Connect.NextHandleFunction[],
513539
indexHtmlTransformer?: (content: string) => Promise<string>,
@@ -616,6 +642,7 @@ export async function setupServer(
616642
indexHtmlTransformer,
617643
extensionMiddleware,
618644
normalizePath,
645+
usedComponentStyles,
619646
}),
620647
createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser),
621648
],

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

+5-1
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));

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

+43-1
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,51 @@ 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('ngcomp');
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+
if (/[_.-A-Za-z0-9]+-c\d{9}$/.test(componentId)) {
92+
loadEsmModule<typeof import('@angular/compiler')>('@angular/compiler')
93+
.then((compilerModule) => {
94+
const encapsulatedData = compilerModule
95+
.encapsulateStyle(new TextDecoder().decode(data))
96+
.replaceAll(COMPONENT_REGEX, componentId);
97+
98+
res.setHeader('Content-Type', 'text/css');
99+
res.setHeader('Cache-Control', 'no-cache');
100+
appendServerConfiguredHeaders(server, res);
101+
res.end(encapsulatedData);
102+
})
103+
.catch((e) => next(e));
104+
105+
return;
106+
} else {
107+
// eslint-disable-next-line no-console
108+
console.error('Invalid component stylesheet ID request: ' + componentId);
109+
}
110+
}
111+
}
112+
}
113+
72114
const mimeType = lookupMimeType(extension);
73115
if (mimeType) {
74116
res.setHeader('Content-Type', mimeType);
75117
}
76118
res.setHeader('Cache-Control', 'no-cache');
77119
appendServerConfiguredHeaders(server, res);
78-
res.end(outputFile.contents);
120+
res.end(data);
79121

80122
return;
81123
}

Diff for: yarn.lock

+1
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ __metadata:
407407
vite: "npm:5.4.4"
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)