Skip to content

Commit a25c88f

Browse files
committed
feat(@angular-devkit/build-angular): switch to use Sass modern API
Sass modern API provides faster compilations times when used in an async manner. |Application compilation duration | Sass API and Compiler| |-- | --| |60852ms | dart-sass legacy sync API| |52666ms | dart-sass modern API| Note: https://github.com/johannesjo/super-productivity was used for benchmarking. Prior art: http://docs/document/d/1CvEceWMpBoEBd8SfvksGMdVHxaZMH93b0EGS3XbR3_Q?resourcekey=0-vFm-xMspT65FZLIyX7xWFQ BREAKING CHANGE: - Deprecated support for tilde import has been removed. Please update the imports by removing the `~`. Before ```scss @import "~font-awesome/scss/font-awesome"; ``` After ```scss @import "font-awesome/scss/font-awesome"; ``` - By default the CLI will use Sass modern API, While not recommended, users can still opt to use legacy API by setting `NG_BUILD_LEGACY_SASS=1`.
1 parent 597bfea commit a25c88f

File tree

9 files changed

+662
-215
lines changed

9 files changed

+662
-215
lines changed

packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,9 @@ describe('Browser Builder styles', () => {
195195
it(`supports material imports in ${ext} files`, async () => {
196196
host.writeMultipleFiles({
197197
[`src/styles.${ext}`]: `
198-
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
199198
@import "@angular/material/prebuilt-themes/indigo-pink.css";
200199
`,
201200
[`src/app/app.component.${ext}`]: `
202-
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
203201
@import "@angular/material/prebuilt-themes/indigo-pink.css";
204202
`,
205203
});
@@ -265,19 +263,14 @@ describe('Browser Builder styles', () => {
265263
});
266264
});
267265

268-
/**
269-
* font-awesome mock to avoid having an extra dependency.
270-
*/
271-
function mockFontAwesomePackage(host: TestProjectHost): void {
266+
it(`supports font-awesome imports`, async () => {
267+
// font-awesome mock to avoid having an extra dependency.
272268
host.writeMultipleFiles({
273269
'node_modules/font-awesome/scss/font-awesome.scss': `
274-
* { color: red }
270+
* { color: red }
275271
`,
276272
});
277-
}
278273

279-
it(`supports font-awesome imports`, async () => {
280-
mockFontAwesomePackage(host);
281274
host.writeMultipleFiles({
282275
'src/styles.scss': `
283276
@import "font-awesome/scss/font-awesome";
@@ -288,19 +281,6 @@ describe('Browser Builder styles', () => {
288281
await browserBuild(architect, host, target, overrides);
289282
});
290283

291-
it(`supports font-awesome imports (tilde)`, async () => {
292-
mockFontAwesomePackage(host);
293-
host.writeMultipleFiles({
294-
'src/styles.scss': `
295-
$fa-font-path: "~font-awesome/fonts";
296-
@import "~font-awesome/scss/font-awesome";
297-
`,
298-
});
299-
300-
const overrides = { styles: [`src/styles.scss`] };
301-
await browserBuild(architect, host, target, overrides);
302-
});
303-
304284
it(`uses autoprefixer`, async () => {
305285
host.writeMultipleFiles({
306286
'src/styles.css': tags.stripIndents`
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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+
9+
import { join } from 'path';
10+
import {
11+
LegacyAsyncImporter as AsyncImporter,
12+
LegacyResult as CompileResult,
13+
LegacyException as Exception,
14+
LegacyImporterResult as ImporterResult,
15+
LegacyImporterThis as ImporterThis,
16+
LegacyOptions as Options,
17+
LegacySyncImporter as SyncImporter,
18+
} from 'sass';
19+
import { MessageChannel, Worker } from 'worker_threads';
20+
import { maxWorkers } from '../utils/environment-options';
21+
22+
/**
23+
* The maximum number of Workers that will be created to execute render requests.
24+
*/
25+
const MAX_RENDER_WORKERS = maxWorkers;
26+
27+
/**
28+
* The callback type for the `dart-sass` asynchronous render function.
29+
*/
30+
type RenderCallback = (error?: Exception, result?: CompileResult) => void;
31+
32+
/**
33+
* An object containing the contextual information for a specific render request.
34+
*/
35+
interface RenderRequest {
36+
id: number;
37+
workerIndex: number;
38+
callback: RenderCallback;
39+
importers?: (SyncImporter | AsyncImporter)[];
40+
}
41+
42+
/**
43+
* A response from the Sass render Worker containing the result of the operation.
44+
*/
45+
interface RenderResponseMessage {
46+
id: number;
47+
error?: Exception;
48+
result?: CompileResult;
49+
}
50+
51+
/**
52+
* A Sass renderer implementation that provides an interface that can be used by Webpack's
53+
* `sass-loader`. The implementation uses a Worker thread to perform the Sass rendering
54+
* with the `dart-sass` package. The `dart-sass` synchronous render function is used within
55+
* the worker which can be up to two times faster than the asynchronous variant.
56+
*/
57+
export class SassLegacyWorkerImplementation {
58+
private readonly workers: Worker[] = [];
59+
private readonly availableWorkers: number[] = [];
60+
private readonly requests = new Map<number, RenderRequest>();
61+
private readonly workerPath = join(__dirname, './worker-legacy.js');
62+
private idCounter = 1;
63+
private nextWorkerIndex = 0;
64+
65+
/**
66+
* Provides information about the Sass implementation.
67+
* This mimics enough of the `dart-sass` value to be used with the `sass-loader`.
68+
*/
69+
get info(): string {
70+
return 'dart-sass\tworker';
71+
}
72+
73+
/**
74+
* The synchronous render function is not used by the `sass-loader`.
75+
*/
76+
renderSync(): never {
77+
throw new Error('Sass renderSync is not supported.');
78+
}
79+
80+
/**
81+
* Asynchronously request a Sass stylesheet to be renderered.
82+
*
83+
* @param options The `dart-sass` options to use when rendering the stylesheet.
84+
* @param callback The function to execute when the rendering is complete.
85+
*/
86+
render(options: Options<'async'>, callback: RenderCallback): void {
87+
// The `functions`, `logger` and `importer` options are JavaScript functions that cannot be transferred.
88+
// If any additional function options are added in the future, they must be excluded as well.
89+
const { functions, importer, logger, ...serializableOptions } = options;
90+
91+
// The CLI's configuration does not use or expose the ability to defined custom Sass functions
92+
if (functions && Object.keys(functions).length > 0) {
93+
throw new Error('Sass custom functions are not supported.');
94+
}
95+
96+
let workerIndex = this.availableWorkers.pop();
97+
if (workerIndex === undefined) {
98+
if (this.workers.length < MAX_RENDER_WORKERS) {
99+
workerIndex = this.workers.length;
100+
this.workers.push(this.createWorker());
101+
} else {
102+
workerIndex = this.nextWorkerIndex++;
103+
if (this.nextWorkerIndex >= this.workers.length) {
104+
this.nextWorkerIndex = 0;
105+
}
106+
}
107+
}
108+
109+
const request = this.createRequest(workerIndex, callback, importer);
110+
this.requests.set(request.id, request);
111+
112+
this.workers[workerIndex].postMessage({
113+
id: request.id,
114+
hasImporter: !!importer,
115+
options: serializableOptions,
116+
});
117+
}
118+
119+
/**
120+
* Shutdown the Sass render worker.
121+
* Executing this method will stop any pending render requests.
122+
*/
123+
close(): void {
124+
for (const worker of this.workers) {
125+
try {
126+
void worker.terminate();
127+
} catch {}
128+
}
129+
this.requests.clear();
130+
}
131+
132+
private createWorker(): Worker {
133+
const { port1: mainImporterPort, port2: workerImporterPort } = new MessageChannel();
134+
const importerSignal = new Int32Array(new SharedArrayBuffer(4));
135+
136+
const worker = new Worker(this.workerPath, {
137+
workerData: { workerImporterPort, importerSignal },
138+
transferList: [workerImporterPort],
139+
});
140+
141+
worker.on('message', (response: RenderResponseMessage) => {
142+
const request = this.requests.get(response.id);
143+
if (!request) {
144+
return;
145+
}
146+
147+
this.requests.delete(response.id);
148+
this.availableWorkers.push(request.workerIndex);
149+
150+
if (response.result) {
151+
// The results are expected to be Node.js `Buffer` objects but will each be transferred as
152+
// a Uint8Array that does not have the expected `toString` behavior of a `Buffer`.
153+
const { css, map, stats } = response.result;
154+
const result: CompileResult = {
155+
// This `Buffer.from` override will use the memory directly and avoid making a copy
156+
css: Buffer.from(css.buffer, css.byteOffset, css.byteLength),
157+
stats,
158+
};
159+
if (map) {
160+
// This `Buffer.from` override will use the memory directly and avoid making a copy
161+
result.map = Buffer.from(map.buffer, map.byteOffset, map.byteLength);
162+
}
163+
request.callback(undefined, result);
164+
} else {
165+
request.callback(response.error);
166+
}
167+
});
168+
169+
mainImporterPort.on(
170+
'message',
171+
({
172+
id,
173+
url,
174+
prev,
175+
fromImport,
176+
}: {
177+
id: number;
178+
url: string;
179+
prev: string;
180+
fromImport: boolean;
181+
}) => {
182+
const request = this.requests.get(id);
183+
if (!request?.importers) {
184+
mainImporterPort.postMessage(null);
185+
Atomics.store(importerSignal, 0, 1);
186+
Atomics.notify(importerSignal, 0);
187+
188+
return;
189+
}
190+
191+
this.processImporters(request.importers, url, prev, fromImport)
192+
.then((result) => {
193+
mainImporterPort.postMessage(result);
194+
})
195+
.catch((error) => {
196+
mainImporterPort.postMessage(error);
197+
})
198+
.finally(() => {
199+
Atomics.store(importerSignal, 0, 1);
200+
Atomics.notify(importerSignal, 0);
201+
});
202+
},
203+
);
204+
205+
mainImporterPort.unref();
206+
207+
return worker;
208+
}
209+
210+
private async processImporters(
211+
importers: Iterable<SyncImporter | AsyncImporter>,
212+
url: string,
213+
prev: string,
214+
fromImport: boolean,
215+
): Promise<ImporterResult> {
216+
let result = null;
217+
for (const importer of importers) {
218+
result = await new Promise<ImporterResult>((resolve) => {
219+
// Importers can be both sync and async
220+
const innerResult = (importer as AsyncImporter).call(
221+
{ fromImport } as ImporterThis,
222+
url,
223+
prev,
224+
resolve,
225+
);
226+
if (innerResult !== undefined) {
227+
resolve(innerResult);
228+
}
229+
});
230+
231+
if (result) {
232+
break;
233+
}
234+
}
235+
236+
return result;
237+
}
238+
239+
private createRequest(
240+
workerIndex: number,
241+
callback: RenderCallback,
242+
importer: SyncImporter | AsyncImporter | (SyncImporter | AsyncImporter)[] | undefined,
243+
): RenderRequest {
244+
return {
245+
id: this.idCounter++,
246+
workerIndex,
247+
callback,
248+
importers: !importer || Array.isArray(importer) ? importer : [importer],
249+
};
250+
}
251+
}

0 commit comments

Comments
 (0)