Skip to content

Commit 816e3cb

Browse files
committed
feat(@angular/build): enable component stylesheet hot replacement by default
When using the `application` builder (default for new projects) with the development server, component style only changes will now automatically replace the styles within the running application without a full reload of the page. No application code changes are necessary and both file-based (`styleUrl`/`styleUrls`) and inline (`styles`) component styles are supported. Within a component template, `<style>` elements and `<link rel="stylesheet">` elements with relative `href` attributes are also supported. If any issues are encountered or it is preferred to not hot replace component styles, the `NG_HMR_CSTYLES=0` environment variable can be used to disable the feature. Setting the `liveReload` option to false will also disable all updates.
1 parent ab63a0e commit 816e3cb

File tree

12 files changed

+62
-33
lines changed

12 files changed

+62
-33
lines changed

packages/angular/build/package.json

+5-5
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@
4848
"lmdb": "3.1.3"
4949
},
5050
"peerDependencies": {
51-
"@angular/compiler": "^19.0.0-next.0",
52-
"@angular/compiler-cli": "^19.0.0-next.0",
53-
"@angular/localize": "^19.0.0-next.0",
54-
"@angular/platform-server": "^19.0.0-next.0",
55-
"@angular/service-worker": "^19.0.0-next.0",
51+
"@angular/compiler": "^19.0.0-next.9",
52+
"@angular/compiler-cli": "^19.0.0-next.9",
53+
"@angular/localize": "^19.0.0-next.9",
54+
"@angular/platform-server": "^19.0.0-next.9",
55+
"@angular/service-worker": "^19.0.0-next.9",
5656
"@angular/ssr": "^0.0.0-PLACEHOLDER",
5757
"less": "^4.2.0",
5858
"postcss": "^8.4.0",

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

+11-6
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,13 @@ export async function* serveWithVite(
137137
process.setSourceMapsEnabled(true);
138138
}
139139

140-
// TODO: Enable by default once full support across CLI and FW is integrated
141-
browserOptions.externalRuntimeStyles = useComponentStyleHmr;
140+
// Enable to support component style hot reloading (`NG_HMR_CSTYLES=0` can be used to disable)
141+
browserOptions.externalRuntimeStyles = !!serverOptions.liveReload && useComponentStyleHmr;
142+
if (browserOptions.externalRuntimeStyles) {
143+
// Preload the @angular/compiler package to avoid first stylesheet request delays.
144+
// Once @angular/build is native ESM, this should be re-evaluated.
145+
void loadEsmModule('@angular/compiler');
146+
}
142147

143148
// Setup the prebundling transformer that will be shared across Vite prebundling requests
144149
const prebundleTransformer = new JavaScriptTransformer(
@@ -166,7 +171,7 @@ export async function* serveWithVite(
166171
explicitBrowser: [],
167172
explicitServer: [],
168173
};
169-
const usedComponentStyles = new Map<string, string[]>();
174+
const usedComponentStyles = new Map<string, Set<string>>();
170175
const templateUpdates = new Map<string, string>();
171176

172177
// Add cleanup logic via a builder teardown.
@@ -423,7 +428,7 @@ async function handleUpdate(
423428
server: ViteDevServer,
424429
serverOptions: NormalizedDevServerOptions,
425430
logger: BuilderContext['logger'],
426-
usedComponentStyles: Map<string, string[]>,
431+
usedComponentStyles: Map<string, Set<string>>,
427432
): Promise<void> {
428433
const updatedFiles: string[] = [];
429434
let destroyAngularServerAppCalled = false;
@@ -470,7 +475,7 @@ async function handleUpdate(
470475
// are not typically reused across components.
471476
const componentIds = usedComponentStyles.get(filePath);
472477
if (componentIds) {
473-
return componentIds.map((id) => ({
478+
return Array.from(componentIds).map((id) => ({
474479
type: 'css-update',
475480
timestamp,
476481
path: `${filePath}?ngcomp` + (id ? `=${id}` : ''),
@@ -582,7 +587,7 @@ export async function setupServer(
582587
prebundleTransformer: JavaScriptTransformer,
583588
target: string[],
584589
zoneless: boolean,
585-
usedComponentStyles: Map<string, string[]>,
590+
usedComponentStyles: Map<string, Set<string>>,
586591
templateUpdates: Map<string, string>,
587592
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
588593
extensionMiddleware?: Connect.NextHandleFunction[],

packages/angular/build/src/tools/angular/angular-host.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface AngularHostOptions {
2525
containingFile: string,
2626
stylesheetFile?: string,
2727
order?: number,
28+
className?: string,
2829
): Promise<string | null>;
2930
processWebWorker(workerFile: string, containingFile: string): string;
3031
}
@@ -197,9 +198,8 @@ export function createAngularCompilerHost(
197198
data,
198199
context.containingFile,
199200
context.resourceFile ?? undefined,
200-
// TODO: Remove once available in compiler-cli types
201-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
202-
(context as any).order,
201+
context.order,
202+
context.className,
203203
);
204204

205205
return typeof result === 'string' ? { content: result } : null;

packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,15 @@ export class ParallelCompilation extends AngularCompilation {
5151
}> {
5252
const stylesheetChannel = new MessageChannel();
5353
// The request identifier is required because Angular can issue multiple concurrent requests
54-
stylesheetChannel.port1.on('message', ({ requestId, data, containingFile, stylesheetFile }) => {
55-
hostOptions
56-
.transformStylesheet(data, containingFile, stylesheetFile)
57-
.then((value) => stylesheetChannel.port1.postMessage({ requestId, value }))
58-
.catch((error) => stylesheetChannel.port1.postMessage({ requestId, error }));
59-
});
54+
stylesheetChannel.port1.on(
55+
'message',
56+
({ requestId, data, containingFile, stylesheetFile, order, className }) => {
57+
hostOptions
58+
.transformStylesheet(data, containingFile, stylesheetFile, order, className)
59+
.then((value) => stylesheetChannel.port1.postMessage({ requestId, value }))
60+
.catch((error) => stylesheetChannel.port1.postMessage({ requestId, error }));
61+
},
62+
);
6063

6164
// The web worker processing is a synchronous operation and uses shared memory combined with
6265
// the Atomics API to block execution here until a response is received.

packages/angular/build/src/tools/angular/compilation/parallel-worker.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function initialize(request: InitRequest) {
4848
fileReplacements: request.fileReplacements,
4949
sourceFileCache,
5050
modifiedFiles: sourceFileCache.modifiedFiles,
51-
transformStylesheet(data, containingFile, stylesheetFile) {
51+
transformStylesheet(data, containingFile, stylesheetFile, order, className) {
5252
const requestId = randomUUID();
5353
const resultPromise = new Promise<string>((resolve, reject) =>
5454
stylesheetRequests.set(requestId, [resolve, reject]),
@@ -59,6 +59,8 @@ export async function initialize(request: InitRequest) {
5959
data,
6060
containingFile,
6161
stylesheetFile,
62+
order,
63+
className,
6264
});
6365

6466
return resultPromise;

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function createCompilerPlugin(
182182
fileReplacements: pluginOptions.fileReplacements,
183183
modifiedFiles,
184184
sourceFileCache: pluginOptions.sourceFileCache,
185-
async transformStylesheet(data, containingFile, stylesheetFile, order) {
185+
async transformStylesheet(data, containingFile, stylesheetFile, order, className) {
186186
let stylesheetResult;
187187

188188
// Stylesheet file only exists for external stylesheets
@@ -202,6 +202,7 @@ export function createCompilerPlugin(
202202
? createHash('sha-256')
203203
.update(containingFile)
204204
.update((order ?? 0).toString())
205+
.update(className ?? '')
205206
.digest('hex')
206207
: undefined,
207208
);

packages/angular/build/src/tools/esbuild/utils.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function logBuildStats(
4343
const browserStats: BundleStats[] = [];
4444
const serverStats: BundleStats[] = [];
4545
let unchangedCount = 0;
46+
let componentStyleChange = false;
4647

4748
for (const { path: file, size, type } of outputFiles) {
4849
// Only display JavaScript and CSS files
@@ -63,6 +64,12 @@ export function logBuildStats(
6364
continue;
6465
}
6566

67+
// Skip logging external component stylesheets used for HMR
68+
if (metafile.outputs[file] && 'ng-component' in metafile.outputs[file]) {
69+
componentStyleChange = true;
70+
continue;
71+
}
72+
6673
const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafile, file);
6774
const stat: BundleStats = {
6875
initial: initial.has(file),
@@ -88,7 +95,11 @@ export function logBuildStats(
8895

8996
return tableText + '\n';
9097
} else if (changedFiles !== undefined) {
91-
return '\nNo output file changes.\n';
98+
if (componentStyleChange) {
99+
return '\nComponent stylesheet(s) changed.\n';
100+
} else {
101+
return '\nNo output file changes.\n';
102+
}
92103
}
93104
if (unchangedCount > 0) {
94105
return `Unchanged output files: ${unchangedCount}`;

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function createAngularAssetsMiddleware(
1616
server: ViteDevServer,
1717
assets: Map<string, string>,
1818
outputFiles: AngularMemoryOutputFiles,
19-
usedComponentStyles: Map<string, string[]>,
19+
usedComponentStyles: Map<string, Set<string>>,
2020
): Connect.NextHandleFunction {
2121
return function angularAssetsMiddleware(req, res, next) {
2222
if (req.url === undefined || res.writableEnded) {
@@ -81,9 +81,9 @@ export function createAngularAssetsMiddleware(
8181
// Record the component style usage for HMR updates
8282
const usedIds = usedComponentStyles.get(pathname);
8383
if (usedIds === undefined) {
84-
usedComponentStyles.set(pathname, [componentId]);
84+
usedComponentStyles.set(pathname, new Set([componentId]));
8585
} else {
86-
usedIds.push(componentId);
86+
usedIds.add(componentId);
8787
}
8888
// Shim the stylesheet if a component ID is provided
8989
if (componentId.length > 0) {

packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ interface AngularSetupMiddlewaresPluginOptions {
4848
assets: Map<string, string>;
4949
extensionMiddleware?: Connect.NextHandleFunction[];
5050
indexHtmlTransformer?: (content: string) => Promise<string>;
51-
usedComponentStyles: Map<string, string[]>;
51+
usedComponentStyles: Map<string, Set<string>>;
5252
templateUpdates: Map<string, string>;
5353
ssrMode: ServerSsrMode;
5454
}

packages/angular/build/src/utils/environment-options.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export const shouldOptimizeChunks =
103103

104104
const hmrComponentStylesVariable = process.env['NG_HMR_CSTYLES'];
105105
export const useComponentStyleHmr =
106-
isPresent(hmrComponentStylesVariable) && isEnabled(hmrComponentStylesVariable);
106+
!isPresent(hmrComponentStylesVariable) || !isDisabled(hmrComponentStylesVariable);
107107

108108
const partialSsrBuildVariable = process.env['NG_BUILD_PARTIAL_SSR'];
109109
export const usePartialSsrBuild =

tests/legacy-cli/e2e/tests/basic/rebuild.ts

+7
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ export default async function () {
99
const validBundleRegEx = esbuild ? /complete\./ : /Compiled successfully\./;
1010
const lazyBundleRegEx = esbuild ? /chunk-/ : /src_app_lazy_lazy_component_ts\.js/;
1111

12+
// Disable component stylesheet HMR to support page reload based rebuild testing.
13+
// Ideally this environment variable would be passed directly to the new serve process
14+
// but this would require signficant test changes due to the existing `ngServe` signature.
15+
const oldHMRValue = process.env['NG_HMR_CSTYLES'];
16+
process.env['NG_HMR_CSTYLES'] = '0';
1217
const port = await ngServe();
18+
process.env['NG_HMR_CSTYLES'] = oldHMRValue;
19+
1320
// Add a lazy route.
1421
await silentNg('generate', 'component', 'lazy');
1522

yarn.lock

+5-5
Original file line numberDiff line numberDiff line change
@@ -401,11 +401,11 @@ __metadata:
401401
vite: "npm:5.4.9"
402402
watchpack: "npm:2.4.2"
403403
peerDependencies:
404-
"@angular/compiler": ^19.0.0-next.0
405-
"@angular/compiler-cli": ^19.0.0-next.0
406-
"@angular/localize": ^19.0.0-next.0
407-
"@angular/platform-server": ^19.0.0-next.0
408-
"@angular/service-worker": ^19.0.0-next.0
404+
"@angular/compiler": ^19.0.0-next.9
405+
"@angular/compiler-cli": ^19.0.0-next.9
406+
"@angular/localize": ^19.0.0-next.9
407+
"@angular/platform-server": ^19.0.0-next.9
408+
"@angular/service-worker": ^19.0.0-next.9
409409
"@angular/ssr": ^0.0.0-PLACEHOLDER
410410
less: ^4.2.0
411411
postcss: ^8.4.0

0 commit comments

Comments
 (0)