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.

Users can temporary opt-out from using the modern API by setting `NG_BUILD_LEGACY_SASS` to `true` or `1`.

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
  • Loading branch information
alan-agius4 committed Jul 22, 2022
1 parent e995bda commit 4fe1a6c
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 99 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.26.0",
"express": "4.18.1",
"font-awesome": "^4.7.0",
"glob": "8.0.3",
"http-proxy": "^1.18.1",
"https-proxy-agent": "5.0.1",
Expand Down
1 change: 0 additions & 1 deletion packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,6 @@ LARGE_SPECS = {
"@npm//@angular/animations",
"@npm//@angular/material",
"@npm//bootstrap",
"@npm//font-awesome",
"@npm//jquery",
"@npm//popper.js",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,54 +6,72 @@
* found in the LICENSE file at https://angular.io/license
*/

import type { Plugin, PluginBuild } from 'esbuild';
import type { LegacyResult } from 'sass';
import { SassWorkerImplementation } from '../../sass/sass-service';
import type { PartialMessage, Plugin, PluginBuild } from 'esbuild';
import type { CompileResult } from 'sass';
import { fileURLToPath } from 'url';

export function createSassPlugin(options: { sourcemap: boolean; includePaths?: string[] }): Plugin {
export function createSassPlugin(options: { sourcemap: boolean; loadPaths?: string[] }): Plugin {
return {
name: 'angular-sass',
setup(build: PluginBuild): void {
let sass: SassWorkerImplementation;
let sass: typeof import('sass');

build.onStart(() => {
sass = new SassWorkerImplementation();
});

build.onEnd(() => {
sass?.close();
build.onStart(async () => {
// Lazily load Sass
sass = await import('sass');
});

build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => {
const result = await new Promise<LegacyResult>((resolve, reject) => {
sass.render(
{
file: args.path,
includePaths: options.includePaths,
indentedSyntax: args.path.endsWith('.sass'),
outputStyle: 'expanded',
sourceMap: options.sourcemap,
sourceMapContents: options.sourcemap,
sourceMapEmbed: options.sourcemap,
quietDeps: true,
},
(error, result) => {
if (error) {
reject(error);
}
if (result) {
resolve(result);
}
try {
const warnings: PartialMessage[] = [];
const { css, sourceMap, loadedUrls } = await sass.compileAsync(args.path, {
style: 'expanded',
sourceMap: options.sourcemap,
sourceMapIncludeSources: options.sourcemap,
quietDeps: true,
logger: {
warn: (text, _options) => {
warnings.push({
text,
});
},
},
);
});

return {
contents: result.css,
loader: 'css',
watchFiles: result.stats.includedFiles,
};
});

return {
loader: 'css',
contents: css + sourceMapToUrlComment(sourceMap),
watchFiles: loadedUrls.map((url) => fileURLToPath(url)),
warnings,
};
} catch (error) {
if (error instanceof sass.Exception) {
const file = error.span.url ? fileURLToPath(error.span.url) : undefined;

return {
loader: 'css',
errors: [
{
text: error.toString(),
},
],
watchFiles: file ? [file] : undefined,
};
}

throw error;
}
});
},
};
}

function sourceMapToUrlComment(sourceMap: CompileResult['sourceMap']): string {
if (!sourceMap) {
return '';
}

const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64');

return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${urlSourceMap}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ async function bundleStylesheet(
entry: Required<Pick<BuildOptions, 'stdin'> | Pick<BuildOptions, 'entryPoints'>>,
options: BundleStylesheetOptions,
) {
const loadPaths = options.includePaths ?? [];
// Needed to resolve node packages.
loadPaths.push(path.join(options.workspaceRoot, 'node_modules'));

// Execute esbuild
const result = await bundle({
...entry,
Expand All @@ -40,9 +44,7 @@ async function bundleStylesheet(
preserveSymlinks: options.preserveSymlinks,
conditions: ['style'],
mainFields: ['style'],
plugins: [
createSassPlugin({ sourcemap: !!options.sourcemap, includePaths: options.includePaths }),
],
plugins: [createSassPlugin({ sourcemap: !!options.sourcemap, loadPaths })],
});

// Extract the result of the bundling from the output files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { Architect } from '@angular-devkit/architect';
import { TestProjectHost } from '@angular-devkit/architect/testing';
import { normalize, tags } from '@angular-devkit/core';
import { dirname } from 'path';
import { browserBuild, createArchitect, host } from '../../../testing/test-utils';
Expand Down Expand Up @@ -259,7 +260,19 @@ describe('Browser Builder styles', () => {
});
});

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

it(`supports font-awesome imports`, async () => {
mockFontAwesomePackage(host);
host.writeMultipleFiles({
'src/styles.scss': `
@import "font-awesome/scss/font-awesome";
Expand All @@ -271,6 +284,7 @@ describe('Browser Builder styles', () => {
});

it(`supports font-awesome imports (tilde)`, async () => {
mockFontAwesomePackage(host);
host.writeMultipleFiles({
'src/styles.scss': `
$fa-font-path: "~font-awesome/fonts";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,6 @@ export const allowMinify = debugOptimize.minify;
*/
const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS'];
export const maxWorkers = isPresent(maxWorkersVariable) ? +maxWorkersVariable : 4;

const legacySassVariable = process.env['NG_BUILD_LEGACY_SASS'];
export const useLegacySass = isPresent(legacySassVariable) && isEnabled(legacySassVariable);
142 changes: 96 additions & 46 deletions packages/angular_devkit/build_angular/src/webpack/configs/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
import * as fs from 'fs';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import * as path from 'path';
import type { LegacyOptions, StringOptionsWithoutImporter } from 'sass';
import { pathToFileURL } from 'url';
import { Configuration, RuleSetUseItem } from 'webpack';
import { StyleElement } from '../../builders/browser/schema';
import { SassWorkerImplementation } from '../../sass/sass-service';
import { WebpackConfigOptions } from '../../utils/build-options';
import { useLegacySass } from '../../utils/environment-options';
import {
AnyComponentStyleBudgetChecker,
PostcssCliResources,
Expand Down Expand Up @@ -88,7 +91,8 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration {
// use includePaths from appConfig
const includePaths =
buildOptions.stylePreprocessorOptions?.includePaths?.map((p) => path.resolve(root, p)) ?? [];

// Needed to resolve node packages.
includePaths.push(path.join(root, 'node_modules'));
// Process global styles.
const {
entryPoints,
Expand All @@ -107,14 +111,16 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration {
);
}

const sassImplementation = new SassWorkerImplementation();
extraPlugins.push({
apply(compiler) {
compiler.hooks.shutdown.tap('sass-worker', () => {
sassImplementation.close();
});
},
});
const sassImplementation = useLegacySass ? new SassWorkerImplementation() : require('sass');
if (useLegacySass) {
extraPlugins.push({
apply(compiler) {
compiler.hooks.shutdown.tap('sass-worker', () => {
sassImplementation.close();
});
},
});
}

const assetNameTemplate = assetNameTemplateFactory(hashFormat);

Expand Down Expand Up @@ -266,24 +272,13 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration {
},
{
loader: require.resolve('sass-loader'),
options: {
implementation: sassImplementation,
sourceMap: true,
sassOptions: {
// Prevent use of `fibers` package as it no longer works in newer Node.js versions
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
// Use expanded as otherwise sass will remove comments that are needed for autoprefixer
// Ex: /* autoprefixer grid: autoplace */
// See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70
outputStyle: 'expanded',
// Silences compiler warnings from 3rd party stylesheets
quietDeps: !buildOptions.verbose,
verbose: buildOptions.verbose,
},
},
options: getSassLoaderOptions(
root,
sassImplementation,
includePaths,
false,
!buildOptions.verbose,
),
},
],
},
Expand All @@ -298,25 +293,13 @@ export function getStylesConfig(wco: WebpackConfigOptions): Configuration {
},
{
loader: require.resolve('sass-loader'),
options: {
implementation: sassImplementation,
sourceMap: true,
sassOptions: {
// Prevent use of `fibers` package as it no longer works in newer Node.js versions
fiber: false,
indentedSyntax: true,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
// Use expanded as otherwise sass will remove comments that are needed for autoprefixer
// Ex: /* autoprefixer grid: autoplace */
// See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70
outputStyle: 'expanded',
// Silences compiler warnings from 3rd party stylesheets
quietDeps: !buildOptions.verbose,
verbose: buildOptions.verbose,
},
},
options: getSassLoaderOptions(
root,
sassImplementation,
includePaths,
true,
!buildOptions.verbose,
),
},
],
},
Expand Down Expand Up @@ -411,3 +394,70 @@ function getTailwindConfigPath({ projectRoot, root }: WebpackConfigOptions): str

return undefined;
}

function getSassCompilerOptions(
root: string,
verbose: boolean,
includePaths: string[],
indentedSyntax: boolean,
):
| StringOptionsWithoutImporter<'async'>
| (LegacyOptions<'async'> & { fiber?: boolean; precision?: number }) {
return useLegacySass
? {
// Prevent use of `fibers` package as it no longer works in newer Node.js versions
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
// Use expanded as otherwise sass will remove comments that are needed for autoprefixer
// Ex: /* autoprefixer grid: autoplace */
// See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70
outputStyle: 'expanded',
// Silences compiler warnings from 3rd party stylesheets
quietDeps: !verbose,
verbose,
indentedSyntax,
}
: {
loadPaths: includePaths,
// Use expanded as otherwise sass will remove comments that are needed for autoprefixer
// Ex: /* autoprefixer grid: autoplace */
// See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70
style: 'expanded',
// Silences compiler warnings from 3rd party stylesheets
quietDeps: !verbose,
verbose,
syntax: indentedSyntax ? 'indented' : 'scss',
importers: [
{
// An importer that redirects relative URLs starting with "~" to
// `node_modules`.
// See: https://sass-lang.com/documentation/js-api/interfaces/FileImporter
findFileUrl(url) {
if (url.charAt(0) !== '~') {
return null;
}

// TODO: issue warning.
return new URL(url.substring(1), pathToFileURL(path.join(root, 'node_modules/')));
},
},
],
};
}

function getSassLoaderOptions(
root: string,
implementation: SassWorkerImplementation | typeof import('sass'),
includePaths: string[],
indentedSyntax: boolean,
verbose: boolean,
): Record<string, unknown> {
return {
sourceMap: true,
api: useLegacySass ? 'legacy' : 'modern',
implementation,
sassOptions: getSassCompilerOptions(root, verbose, includePaths, indentedSyntax),
};
}
3 changes: 0 additions & 3 deletions scripts/validate-licenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,6 @@ const ignoredPackages = [
'pako@1.0.11', // MIT but broken license in package.json
'fs-monkey@1.0.1', // Unlicense but missing license field (PR: https://github.com/streamich/fs-monkey/pull/209)
'memfs@3.2.0', // Unlicense but missing license field (PR: https://github.com/streamich/memfs/pull/594)

// * Other
'font-awesome@4.7.0', // (OFL-1.1 AND MIT)
];

// Ignore own packages (all MIT)
Expand Down
Loading

0 comments on commit 4fe1a6c

Please sign in to comment.