Skip to content

Commit eb30a92

Browse files
alan-agius4clydin
authored andcommitted
feat(@angular-devkit/build-angular): enable inlining of critical CSS optimizations
This is another feature that we mentioned in the Eliminate Render Blocking Requests RFC (#18730) Inlining of critical CSS is turned off by default. To opt-in this feature set `inlineCritical` to `true`. Example: ```json "configurations": { "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "optimization": { "styles": { "minify": true, "inlineCritical": true, } }, ``` To learn more about critical CSS see; https://web.dev/defer-non-critical-css https://web.dev/extract-critical-css/ In a future version of the Angular CLI `inlineCritical` will be enabled by default. Closes: #17966 Closes: #11395 Closes: #19445
1 parent 450d999 commit eb30a92

26 files changed

+528
-54
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@
142142
"conventional-commits-parser": "^3.0.0",
143143
"copy-webpack-plugin": "6.3.2",
144144
"core-js": "3.8.0",
145+
"critters": "0.0.6",
145146
"css-loader": "5.0.1",
146147
"cssnano": "4.1.10",
147148
"debug": "^4.1.1",

packages/angular/cli/lib/config/schema.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@
715715
"additionalProperties": false
716716
},
717717
"optimization": {
718-
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-and-source-map-configuration.",
718+
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-and-source-map-configuration.",
719719
"oneOf": [
720720
{
721721
"type": "object",
@@ -726,9 +726,29 @@
726726
"default": true
727727
},
728728
"styles": {
729-
"type": "boolean",
730729
"description": "Enables optimization of the styles output.",
731-
"default": true
730+
"default": true,
731+
"oneOf": [
732+
{
733+
"type": "object",
734+
"properties": {
735+
"minify": {
736+
"type": "boolean",
737+
"description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.",
738+
"default": true
739+
},
740+
"inlineCritical": {
741+
"type": "boolean",
742+
"description": "Extract and inline critical CSS definitions to improve first paint time.",
743+
"default": false
744+
}
745+
},
746+
"additionalProperties": false
747+
},
748+
{
749+
"type": "boolean"
750+
}
751+
]
732752
},
733753
"fonts": {
734754
"description": "Enables optimization for fonts. This requires internet access.",

packages/angular_devkit/build_angular/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ ts_library(
145145
"@npm//circular-dependency-plugin",
146146
"@npm//copy-webpack-plugin",
147147
"@npm//core-js",
148+
"@npm//critters",
148149
"@npm//css-loader",
149150
"@npm//cssnano",
150151
"@npm//file-loader",

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"circular-dependency-plugin": "5.2.2",
2929
"copy-webpack-plugin": "6.3.2",
3030
"core-js": "3.8.0",
31+
"critters": "0.0.6",
3132
"css-loader": "5.0.1",
3233
"cssnano": "4.1.10",
3334
"file-loader": "6.2.0",

packages/angular_devkit/build_angular/src/app-shell/app-shell_spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ describe('AppShell Builder', () => {
2222
afterEach(async () => host.restore().toPromise());
2323

2424
const appShellRouteFiles = {
25+
'src/styles.css': `
26+
p { color: #000 }
27+
`,
2528
'src/app/app-shell/app-shell.component.html': `
2629
<p>
2730
app-shell works!
@@ -262,4 +265,24 @@ describe('AppShell Builder', () => {
262265
// Close the express server.
263266
server.close();
264267
});
268+
269+
it('critical CSS is inlined', async () => {
270+
host.writeMultipleFiles(appShellRouteFiles);
271+
const overrides = {
272+
route: 'shell',
273+
browserTarget: 'app:build:production,inline-critical-css',
274+
};
275+
276+
const run = await architect.scheduleTarget(target, overrides);
277+
const output = await run.result;
278+
await run.stop();
279+
280+
expect(output.success).toBe(true);
281+
const fileName = 'dist/index.html';
282+
const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName)));
283+
284+
expect(content).toContain('app-shell works!');
285+
expect(content).toContain('p{color:#000;}');
286+
expect(content).toMatch(/<link rel="stylesheet" href="styles\.[a-z0-9]+\.css" media="print" onload="this\.media='all'">/);
287+
});
265288
});

packages/angular_devkit/build_angular/src/app-shell/index.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import * as path from 'path';
1818
import { BrowserBuilderOutput } from '../browser';
1919
import { Schema as BrowserBuilderSchema } from '../browser/schema';
2020
import { ServerBuilderOutput } from '../server';
21+
import { normalizeOptimization } from '../utils';
22+
import { readFile, writeFile } from '../utils/fs';
23+
import { InlineCriticalCssProcessor } from '../utils/index-file/inline-critical-css';
2124
import { augmentAppWithServiceWorker } from '../utils/service-worker';
2225
import { Spinner } from '../utils/spinner';
2326
import { Schema as BuildWebpackAppShellSchema } from './schema';
@@ -27,16 +30,18 @@ async function _renderUniversal(
2730
context: BuilderContext,
2831
browserResult: BrowserBuilderOutput,
2932
serverResult: ServerBuilderOutput,
33+
spinner: Spinner,
3034
): Promise<BrowserBuilderOutput> {
3135
// Get browser target options.
3236
const browserTarget = targetFromTargetString(options.browserTarget);
33-
const rawBrowserOptions = await context.getTargetOptions(browserTarget);
37+
const rawBrowserOptions = (await context.getTargetOptions(browserTarget)) as JsonObject & BrowserBuilderSchema;
3438
const browserBuilderName = await context.getBuilderNameForTarget(browserTarget);
3539
const browserOptions = await context.validateOptions<JsonObject & BrowserBuilderSchema>(
3640
rawBrowserOptions,
3741
browserBuilderName,
3842
);
3943

44+
4045
// Initialize zone.js
4146
const root = context.workspaceRoot;
4247
const zonePackage = require.resolve('zone.js', { paths: [root] });
@@ -54,10 +59,18 @@ async function _renderUniversal(
5459
normalize((projectMetadata.root as string) || ''),
5560
);
5661

62+
const { styles } = normalizeOptimization(browserOptions.optimization);
63+
const inlineCriticalCssProcessor = styles.inlineCritical
64+
? new InlineCriticalCssProcessor({
65+
minify: styles.minify,
66+
deployUrl: browserOptions.deployUrl,
67+
})
68+
: undefined;
69+
5770
for (const outputPath of browserResult.outputPaths) {
5871
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
5972
const browserIndexOutputPath = path.join(outputPath, 'index.html');
60-
const indexHtml = fs.readFileSync(browserIndexOutputPath, 'utf8');
73+
const indexHtml = await readFile(browserIndexOutputPath, 'utf8');
6174
const serverBundlePath = await _getServerModuleBundlePath(options, context, serverResult, localeDirectory);
6275

6376
const {
@@ -86,13 +99,25 @@ async function _renderUniversal(
8699
url: options.route,
87100
};
88101

89-
const html = await renderModuleFn(AppServerModuleDef, renderOpts);
102+
let html = await renderModuleFn(AppServerModuleDef, renderOpts);
90103
// Overwrite the client index file.
91104
const outputIndexPath = options.outputIndexPath
92105
? path.join(root, options.outputIndexPath)
93106
: browserIndexOutputPath;
94107

95-
fs.writeFileSync(outputIndexPath, html);
108+
if (inlineCriticalCssProcessor) {
109+
const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, { outputPath });
110+
html = content;
111+
112+
if (warnings.length || errors.length) {
113+
spinner.stop();
114+
warnings.forEach(m => context.logger.warn(m));
115+
errors.forEach(m => context.logger.error(m));
116+
spinner.start();
117+
}
118+
}
119+
120+
await writeFile(outputIndexPath, html);
96121

97122
if (browserOptions.serviceWorker) {
98123
await augmentAppWithServiceWorker(
@@ -145,9 +170,15 @@ async function _appShellBuilder(
145170

146171
// Never run the browser target in watch mode.
147172
// If service worker is needed, it will be added in _renderUniversal();
173+
const browserOptions = (await context.getTargetOptions(browserTarget)) as JsonObject & BrowserBuilderSchema;
174+
175+
const optimization = normalizeOptimization(browserOptions.optimization);
176+
optimization.styles.inlineCritical = false;
177+
148178
const browserTargetRun = await context.scheduleTarget(browserTarget, {
149179
watch: false,
150180
serviceWorker: false,
181+
optimization: (optimization as unknown as JsonObject),
151182
});
152183
const serverTargetRun = await context.scheduleTarget(serverTarget, {
153184
watch: false,
@@ -169,7 +200,7 @@ async function _appShellBuilder(
169200

170201
spinner = new Spinner();
171202
spinner.start('Generating application shell...');
172-
const result = await _renderUniversal(options, context, browserResult, serverResult);
203+
const result = await _renderUniversal(options, context, browserResult, serverResult, spinner);
173204
spinner.succeed('Application shell generation complete.');
174205

175206
return result;

packages/angular_devkit/build_angular/src/browser/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ export function buildWebpackBrowser(
685685

686686
for (const [locale, outputPath] of outputPaths.entries()) {
687687
try {
688-
const content = await indexHtmlGenerator.process({
688+
const { content, warnings, errors } = await indexHtmlGenerator.process({
689689
baseHref: getLocaleBaseHref(i18n, locale) || options.baseHref,
690690
// i18nLocale is used when Ivy is disabled
691691
lang: locale || options.i18nLocale,
@@ -695,6 +695,13 @@ export function buildWebpackBrowser(
695695
moduleFiles: mapEmittedFilesToFileInfo(moduleFiles),
696696
});
697697

698+
if (warnings.length || errors.length) {
699+
spinner.stop();
700+
warnings.forEach(m => context.logger.warn(m));
701+
errors.forEach(m => context.logger.error(m));
702+
spinner.start();
703+
}
704+
698705
const indexOutput = path.join(outputPath, getIndexOutputFile(options.index));
699706
await mkdir(path.dirname(indexOutput), { recursive: true });
700707
await writeFile(indexOutput, content);

packages/angular_devkit/build_angular/src/browser/schema.json

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"additionalProperties": false
5858
},
5959
"optimization": {
60-
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-and-source-map-configuration.",
60+
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.io/guide/workspace-config#optimization-and-source-map-configuration.",
6161
"x-user-analytics": 16,
6262
"default": false,
6363
"oneOf": [
@@ -70,9 +70,29 @@
7070
"default": true
7171
},
7272
"styles": {
73-
"type": "boolean",
7473
"description": "Enables optimization of the styles output.",
75-
"default": true
74+
"default": true,
75+
"oneOf": [
76+
{
77+
"type": "object",
78+
"properties": {
79+
"minify": {
80+
"type": "boolean",
81+
"description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.",
82+
"default": true
83+
},
84+
"inlineCritical": {
85+
"type": "boolean",
86+
"description": "Extract and inline critical CSS definitions to improve first paint time.",
87+
"default": false
88+
}
89+
},
90+
"additionalProperties": false
91+
},
92+
{
93+
"type": "boolean"
94+
}
95+
]
7696
},
7797
"fonts": {
7898
"description": "Enables optimization for fonts. This requires internet access.",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { Architect } from '@angular-devkit/architect';
9+
import { browserBuild, createArchitect, host } from '../../test-utils';
10+
11+
describe('Browser Builder inline critical CSS optimization', () => {
12+
const target = { project: 'app', target: 'build' };
13+
const overrides = {
14+
optimization: {
15+
scripts: false,
16+
styles: {
17+
minify: true,
18+
inlineCritical: true,
19+
},
20+
fonts: false,
21+
},
22+
};
23+
24+
let architect: Architect;
25+
26+
beforeEach(async () => {
27+
await host.initialize().toPromise();
28+
architect = (await createArchitect(host.root())).architect;
29+
host.writeMultipleFiles({
30+
'src/styles.css': `
31+
body { color: #000 }
32+
`,
33+
});
34+
});
35+
36+
afterEach(async () => host.restore().toPromise());
37+
38+
it('works', async () => {
39+
const { files } = await browserBuild(architect, host, target, overrides);
40+
const html = await files['index.html'];
41+
expect(html).toContain(`<link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'">`);
42+
expect(html).toContain(`body{color:#000;}`);
43+
});
44+
45+
it('works with deployUrl', async () => {
46+
const { files } = await browserBuild(architect, host, target, { ...overrides, deployUrl: 'http://cdn.com/' });
47+
const html = await files['index.html'];
48+
expect(html).toContain(`<link rel="stylesheet" href="http://cdn.com/styles.css" media="print" onload="this.media='all'">`);
49+
expect(html).toContain(`body{color:#000;}`);
50+
});
51+
52+
it('should not inline critical css when option is disabled', async () => {
53+
const { files } = await browserBuild(architect, host, target, { optimization: false });
54+
const html = await files['index.html'];
55+
expect(html).toContain(`<link rel="stylesheet" href="styles.css">`);
56+
expect(html).not.toContain(`body{color:#000;}`);
57+
});
58+
});

packages/angular_devkit/build_angular/src/dev-server/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,11 @@ export function serveWebpackBrowser(
107107
);
108108

109109
// Get dev-server only options.
110-
const devServerOptions = (Object.keys(options) as (keyof Schema)[])
110+
type DevServerOptions = Partial<Omit<Schema,
111+
'watch' | 'optimization' | 'aot' | 'sourceMap' | 'vendorChunk' | 'commonChunk' | 'baseHref' | 'progress' | 'poll' | 'verbose' | 'deployUrl'>>;
112+
const devServerOptions: DevServerOptions = (Object.keys(options) as (keyof Schema)[])
111113
.filter(key => !devServerBuildOverriddenKeys.includes(key) && key !== 'browserTarget')
112-
.reduce<Partial<Schema>>(
114+
.reduce<DevServerOptions>(
113115
(previous, key) => ({
114116
...previous,
115117
[key]: options[key],
@@ -288,7 +290,7 @@ export function serveWebpackBrowser(
288290
);
289291
}
290292

291-
if (normalizedOptimization.scripts || normalizedOptimization.styles) {
293+
if (normalizedOptimization.scripts || normalizedOptimization.styles.minify) {
292294
logger.error(tags.stripIndents`
293295
****************************************************************************************
294296
This is a simple server for use in testing or debugging Angular applications locally.

0 commit comments

Comments
 (0)