Skip to content

Commit

Permalink
feat(@angular/build): enable component stylesheet hot replacement by …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
clydin committed Oct 16, 2024
1 parent ab63a0e commit 816e3cb
Show file tree
Hide file tree
Showing 12 changed files with 62 additions and 33 deletions.
10 changes: 5 additions & 5 deletions packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@
"lmdb": "3.1.3"
},
"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",
"@angular/service-worker": "^19.0.0-next.0",
"@angular/compiler": "^19.0.0-next.9",
"@angular/compiler-cli": "^19.0.0-next.9",
"@angular/localize": "^19.0.0-next.9",
"@angular/platform-server": "^19.0.0-next.9",
"@angular/service-worker": "^19.0.0-next.9",
"@angular/ssr": "^0.0.0-PLACEHOLDER",
"less": "^4.2.0",
"postcss": "^8.4.0",
Expand Down
17 changes: 11 additions & 6 deletions packages/angular/build/src/builders/dev-server/vite-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,13 @@ export async function* serveWithVite(
process.setSourceMapsEnabled(true);
}

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

// Setup the prebundling transformer that will be shared across Vite prebundling requests
const prebundleTransformer = new JavaScriptTransformer(
Expand Down Expand Up @@ -166,7 +171,7 @@ export async function* serveWithVite(
explicitBrowser: [],
explicitServer: [],
};
const usedComponentStyles = new Map<string, string[]>();
const usedComponentStyles = new Map<string, Set<string>>();
const templateUpdates = new Map<string, string>();

// Add cleanup logic via a builder teardown.
Expand Down Expand Up @@ -423,7 +428,7 @@ async function handleUpdate(
server: ViteDevServer,
serverOptions: NormalizedDevServerOptions,
logger: BuilderContext['logger'],
usedComponentStyles: Map<string, string[]>,
usedComponentStyles: Map<string, Set<string>>,
): Promise<void> {
const updatedFiles: string[] = [];
let destroyAngularServerAppCalled = false;
Expand Down Expand Up @@ -470,7 +475,7 @@ async function handleUpdate(
// are not typically reused across components.
const componentIds = usedComponentStyles.get(filePath);
if (componentIds) {
return componentIds.map((id) => ({
return Array.from(componentIds).map((id) => ({
type: 'css-update',
timestamp,
path: `${filePath}?ngcomp` + (id ? `=${id}` : ''),
Expand Down Expand Up @@ -582,7 +587,7 @@ export async function setupServer(
prebundleTransformer: JavaScriptTransformer,
target: string[],
zoneless: boolean,
usedComponentStyles: Map<string, string[]>,
usedComponentStyles: Map<string, Set<string>>,
templateUpdates: Map<string, string>,
prebundleLoaderExtensions: EsbuildLoaderOption | undefined,
extensionMiddleware?: Connect.NextHandleFunction[],
Expand Down
6 changes: 3 additions & 3 deletions packages/angular/build/src/tools/angular/angular-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface AngularHostOptions {
containingFile: string,
stylesheetFile?: string,
order?: number,
className?: string,
): Promise<string | null>;
processWebWorker(workerFile: string, containingFile: string): string;
}
Expand Down Expand Up @@ -197,9 +198,8 @@ export function createAngularCompilerHost(
data,
context.containingFile,
context.resourceFile ?? undefined,
// TODO: Remove once available in compiler-cli types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(context as any).order,
context.order,
context.className,
);

return typeof result === 'string' ? { content: result } : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,15 @@ export class ParallelCompilation extends AngularCompilation {
}> {
const stylesheetChannel = new MessageChannel();
// The request identifier is required because Angular can issue multiple concurrent requests
stylesheetChannel.port1.on('message', ({ requestId, data, containingFile, stylesheetFile }) => {
hostOptions
.transformStylesheet(data, containingFile, stylesheetFile)
.then((value) => stylesheetChannel.port1.postMessage({ requestId, value }))
.catch((error) => stylesheetChannel.port1.postMessage({ requestId, error }));
});
stylesheetChannel.port1.on(
'message',
({ requestId, data, containingFile, stylesheetFile, order, className }) => {
hostOptions
.transformStylesheet(data, containingFile, stylesheetFile, order, className)
.then((value) => stylesheetChannel.port1.postMessage({ requestId, value }))
.catch((error) => stylesheetChannel.port1.postMessage({ requestId, error }));
},
);

// The web worker processing is a synchronous operation and uses shared memory combined with
// the Atomics API to block execution here until a response is received.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function initialize(request: InitRequest) {
fileReplacements: request.fileReplacements,
sourceFileCache,
modifiedFiles: sourceFileCache.modifiedFiles,
transformStylesheet(data, containingFile, stylesheetFile) {
transformStylesheet(data, containingFile, stylesheetFile, order, className) {
const requestId = randomUUID();
const resultPromise = new Promise<string>((resolve, reject) =>
stylesheetRequests.set(requestId, [resolve, reject]),
Expand All @@ -59,6 +59,8 @@ export async function initialize(request: InitRequest) {
data,
containingFile,
stylesheetFile,
order,
className,
});

return resultPromise;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export function createCompilerPlugin(
fileReplacements: pluginOptions.fileReplacements,
modifiedFiles,
sourceFileCache: pluginOptions.sourceFileCache,
async transformStylesheet(data, containingFile, stylesheetFile, order) {
async transformStylesheet(data, containingFile, stylesheetFile, order, className) {
let stylesheetResult;

// Stylesheet file only exists for external stylesheets
Expand All @@ -202,6 +202,7 @@ export function createCompilerPlugin(
? createHash('sha-256')
.update(containingFile)
.update((order ?? 0).toString())
.update(className ?? '')
.digest('hex')
: undefined,
);
Expand Down
13 changes: 12 additions & 1 deletion packages/angular/build/src/tools/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function logBuildStats(
const browserStats: BundleStats[] = [];
const serverStats: BundleStats[] = [];
let unchangedCount = 0;
let componentStyleChange = false;

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

// Skip logging external component stylesheets used for HMR
if (metafile.outputs[file] && 'ng-component' in metafile.outputs[file]) {
componentStyleChange = true;
continue;
}

const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafile, file);
const stat: BundleStats = {
initial: initial.has(file),
Expand All @@ -88,7 +95,11 @@ export function logBuildStats(

return tableText + '\n';
} else if (changedFiles !== undefined) {
return '\nNo output file changes.\n';
if (componentStyleChange) {
return '\nComponent stylesheet(s) changed.\n';
} else {
return '\nNo output file changes.\n';
}
}
if (unchangedCount > 0) {
return `Unchanged output files: ${unchangedCount}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function createAngularAssetsMiddleware(
server: ViteDevServer,
assets: Map<string, string>,
outputFiles: AngularMemoryOutputFiles,
usedComponentStyles: Map<string, string[]>,
usedComponentStyles: Map<string, Set<string>>,
): Connect.NextHandleFunction {
return function angularAssetsMiddleware(req, res, next) {
if (req.url === undefined || res.writableEnded) {
Expand Down Expand Up @@ -81,9 +81,9 @@ export function createAngularAssetsMiddleware(
// Record the component style usage for HMR updates
const usedIds = usedComponentStyles.get(pathname);
if (usedIds === undefined) {
usedComponentStyles.set(pathname, [componentId]);
usedComponentStyles.set(pathname, new Set([componentId]));
} else {
usedIds.push(componentId);
usedIds.add(componentId);
}
// Shim the stylesheet if a component ID is provided
if (componentId.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface AngularSetupMiddlewaresPluginOptions {
assets: Map<string, string>;
extensionMiddleware?: Connect.NextHandleFunction[];
indexHtmlTransformer?: (content: string) => Promise<string>;
usedComponentStyles: Map<string, string[]>;
usedComponentStyles: Map<string, Set<string>>;
templateUpdates: Map<string, string>;
ssrMode: ServerSsrMode;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/build/src/utils/environment-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export const shouldOptimizeChunks =

const hmrComponentStylesVariable = process.env['NG_HMR_CSTYLES'];
export const useComponentStyleHmr =
isPresent(hmrComponentStylesVariable) && isEnabled(hmrComponentStylesVariable);
!isPresent(hmrComponentStylesVariable) || !isDisabled(hmrComponentStylesVariable);

const partialSsrBuildVariable = process.env['NG_BUILD_PARTIAL_SSR'];
export const usePartialSsrBuild =
Expand Down
7 changes: 7 additions & 0 deletions tests/legacy-cli/e2e/tests/basic/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ export default async function () {
const validBundleRegEx = esbuild ? /complete\./ : /Compiled successfully\./;
const lazyBundleRegEx = esbuild ? /chunk-/ : /src_app_lazy_lazy_component_ts\.js/;

// Disable component stylesheet HMR to support page reload based rebuild testing.
// Ideally this environment variable would be passed directly to the new serve process
// but this would require signficant test changes due to the existing `ngServe` signature.
const oldHMRValue = process.env['NG_HMR_CSTYLES'];
process.env['NG_HMR_CSTYLES'] = '0';
const port = await ngServe();
process.env['NG_HMR_CSTYLES'] = oldHMRValue;

// Add a lazy route.
await silentNg('generate', 'component', 'lazy');

Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,11 @@ __metadata:
vite: "npm:5.4.9"
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
"@angular/service-worker": ^19.0.0-next.0
"@angular/compiler": ^19.0.0-next.9
"@angular/compiler-cli": ^19.0.0-next.9
"@angular/localize": ^19.0.0-next.9
"@angular/platform-server": ^19.0.0-next.9
"@angular/service-worker": ^19.0.0-next.9
"@angular/ssr": ^0.0.0-PLACEHOLDER
less: ^4.2.0
postcss: ^8.4.0
Expand Down

0 comments on commit 816e3cb

Please sign in to comment.