Skip to content

Commit d0dae7f

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 While not recommended, users can still opt to use legacy API by setting `NG_BUILD_LEGACY_SASS=1`. 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"; ```
1 parent f567ffa commit d0dae7f

File tree

9 files changed

+658
-213
lines changed

9 files changed

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

0 commit comments

Comments
 (0)