Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): switch to use Sass modern API
Browse files Browse the repository at this point in the history
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";
```
  • Loading branch information
alan-agius4 committed Aug 5, 2022
1 parent 88c3b71 commit 80988aa
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 153 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,14 @@ describe('Browser Builder styles', () => {
});
});

/**
* font-awesome mock to avoid having an extra dependency.
*/
function mockFontAwesomePackage(host: TestProjectHost): void {
it(`supports font-awesome imports`, async () => {
// font-awesome mock to avoid having an extra dependency.
host.writeMultipleFiles({
'node_modules/font-awesome/scss/font-awesome.scss': `
* { color: red }
* { color: red }
`,
});
}

it(`supports font-awesome imports`, async () => {
mockFontAwesomePackage(host);
host.writeMultipleFiles({
'src/styles.scss': `
@import "font-awesome/scss/font-awesome";
Expand All @@ -283,19 +278,6 @@ describe('Browser Builder styles', () => {
await browserBuild(architect, host, target, overrides);
});

it(`supports font-awesome imports (tilde)`, async () => {
mockFontAwesomePackage(host);
host.writeMultipleFiles({
'src/styles.scss': `
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
`,
});

const overrides = { styles: [`src/styles.scss`] };
await browserBuild(architect, host, target, overrides);
});

it(`uses autoprefixer`, async () => {
host.writeMultipleFiles({
'src/styles.css': tags.stripIndents`
Expand Down
172 changes: 96 additions & 76 deletions packages/angular_devkit/build_angular/src/sass/sass-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
*/

import {
LegacyAsyncImporter as AsyncImporter,
LegacyResult as CompileResult,
LegacyException as Exception,
LegacyImporterResult as ImporterResult,
LegacyImporterThis as ImporterThis,
LegacyOptions as Options,
LegacySyncImporter as SyncImporter,
CompileResult,
Exception,
FileImporter,
Importer,
StringOptionsWithImporter,
StringOptionsWithoutImporter,
} from 'sass';
import { fileURLToPath, pathToFileURL } from 'url';
import { MessageChannel, Worker } from 'worker_threads';
import { maxWorkers } from '../utils/environment-options';

Expand All @@ -28,23 +28,34 @@ const MAX_RENDER_WORKERS = maxWorkers;
*/
type RenderCallback = (error?: Exception, result?: CompileResult) => void;

type FileImporterOptions = Parameters<FileImporter['findFileUrl']>[1];

/**
* An object containing the contextual information for a specific render request.
*/
interface RenderRequest {
id: number;
workerIndex: number;
callback: RenderCallback;
importers?: (SyncImporter | AsyncImporter)[];
importers?: Importers[];
}

/**
* All available importer types.
*/
type Importers =
| Importer<'sync'>
| Importer<'async'>
| FileImporter<'sync'>
| FileImporter<'async'>;

/**
* A response from the Sass render Worker containing the result of the operation.
*/
interface RenderResponseMessage {
id: number;
error?: Exception;
result?: CompileResult;
result?: Omit<CompileResult, 'loadedUrls'> & { loadedUrls: string[] };
}

/**
Expand All @@ -71,46 +82,77 @@ export class SassWorkerImplementation {
/**
* The synchronous render function is not used by the `sass-loader`.
*/
renderSync(): never {
throw new Error('Sass renderSync is not supported.');
compileString(): never {
throw new Error('Sass compileString is not supported.');
}

/**
* Asynchronously request a Sass stylesheet to be renderered.
*
* @param source The contents to compile.
* @param options The `dart-sass` options to use when rendering the stylesheet.
* @param callback The function to execute when the rendering is complete.
*/
render(options: Options<'async'>, callback: RenderCallback): void {
compileStringAsync(
source: string,
options: StringOptionsWithImporter<'async'> | StringOptionsWithoutImporter<'async'>,
): Promise<CompileResult> {
// The `functions`, `logger` and `importer` options are JavaScript functions that cannot be transferred.
// If any additional function options are added in the future, they must be excluded as well.
const { functions, importer, logger, ...serializableOptions } = options;
const { functions, importers, url, logger, ...serializableOptions } = options;

// The CLI's configuration does not use or expose the ability to defined custom Sass functions
if (functions && Object.keys(functions).length > 0) {
throw new Error('Sass custom functions are not supported.');
}

let workerIndex = this.availableWorkers.pop();
if (workerIndex === undefined) {
if (this.workers.length < MAX_RENDER_WORKERS) {
workerIndex = this.workers.length;
this.workers.push(this.createWorker());
} else {
workerIndex = this.nextWorkerIndex++;
if (this.nextWorkerIndex >= this.workers.length) {
this.nextWorkerIndex = 0;
return new Promise<CompileResult>((resolve, reject) => {
let workerIndex = this.availableWorkers.pop();
if (workerIndex === undefined) {
if (this.workers.length < MAX_RENDER_WORKERS) {
workerIndex = this.workers.length;
this.workers.push(this.createWorker());
} else {
workerIndex = this.nextWorkerIndex++;
if (this.nextWorkerIndex >= this.workers.length) {
this.nextWorkerIndex = 0;
}
}
}
}

const request = this.createRequest(workerIndex, callback, importer);
this.requests.set(request.id, request);
const callback: RenderCallback = (error, result) => {
if (error) {
const url = error?.span.url as string | undefined;
if (url) {
error.span.url = pathToFileURL(url);
}

this.workers[workerIndex].postMessage({
id: request.id,
hasImporter: !!importer,
options: serializableOptions,
reject(error);

return;
}

if (!result) {
reject('No result.');

return;
}

resolve(result);
};

const request = this.createRequest(workerIndex, callback, importers);
this.requests.set(request.id, request);

this.workers[workerIndex].postMessage({
id: request.id,
source,
hasImporter: !!importers?.length,
options: {
...serializableOptions,
// URL is not serializable so to convert to string here and back to URL in the worker.
url: url ? fileURLToPath(url) : undefined,
},
});
});
}

Expand Down Expand Up @@ -147,37 +189,19 @@ export class SassWorkerImplementation {
this.availableWorkers.push(request.workerIndex);

if (response.result) {
// The results are expected to be Node.js `Buffer` objects but will each be transferred as
// a Uint8Array that does not have the expected `toString` behavior of a `Buffer`.
const { css, map, stats } = response.result;
const result: CompileResult = {
// This `Buffer.from` override will use the memory directly and avoid making a copy
css: Buffer.from(css.buffer, css.byteOffset, css.byteLength),
stats,
};
if (map) {
// This `Buffer.from` override will use the memory directly and avoid making a copy
result.map = Buffer.from(map.buffer, map.byteOffset, map.byteLength);
}
request.callback(undefined, result);
request.callback(undefined, {
...response.result,
// URL is not serializable so in the worker we convert to string and here back to URL.
loadedUrls: response.result.loadedUrls.map((p) => pathToFileURL(p)),
});
} else {
request.callback(response.error);
}
});

mainImporterPort.on(
'message',
({
id,
url,
prev,
fromImport,
}: {
id: number;
url: string;
prev: string;
fromImport: boolean;
}) => {
({ id, url, options }: { id: number; url: string; options: FileImporterOptions }) => {
const request = this.requests.get(id);
if (!request?.importers) {
mainImporterPort.postMessage(null);
Expand All @@ -187,7 +211,7 @@ export class SassWorkerImplementation {
return;
}

this.processImporters(request.importers, url, prev, fromImport)
this.processImporters(request.importers, url, options)
.then((result) => {
mainImporterPort.postMessage(result);
})
Expand All @@ -207,44 +231,40 @@ export class SassWorkerImplementation {
}

private async processImporters(
importers: Iterable<SyncImporter | AsyncImporter>,
importers: Iterable<Importers>,
url: string,
prev: string,
fromImport: boolean,
): Promise<ImporterResult> {
let result = null;
options: FileImporterOptions,
): Promise<string | null> {
for (const importer of importers) {
result = await new Promise<ImporterResult>((resolve) => {
// Importers can be both sync and async
const innerResult = (importer as AsyncImporter).call(
{ fromImport } as ImporterThis,
url,
prev,
resolve,
);
if (innerResult !== undefined) {
resolve(innerResult);
}
});
if (this.isImporter(importer)) {
// Importer
throw new Error('Only File Importers are supported.');
}

// File importer (Can be sync or aync).
const result = await importer.findFileUrl(url, options);
if (result) {
break;
return fileURLToPath(result);
}
}

return result;
return null;
}

private createRequest(
workerIndex: number,
callback: RenderCallback,
importer: SyncImporter | AsyncImporter | (SyncImporter | AsyncImporter)[] | undefined,
importers: Importers[] | undefined,
): RenderRequest {
return {
id: this.idCounter++,
workerIndex,
callback,
importers: !importer || Array.isArray(importer) ? importer : [importer],
importers,
};
}

private isImporter(value: Importers): value is Importer {
return 'canonicalize' in value && 'load' in value;
}
}
Loading

0 comments on commit 80988aa

Please sign in to comment.