fix(angular): restore esm2022 bundle and drop fesm2022 in ng-packagr-lite executor (#29615)

## Current Behavior

In Nx 20.2.0, the `ng-packagr-lite` executor stopped producing the
ESM2022 outputs and started producing the FESM2022 outputs. This was due
to the Angular Package Format (APF) dropping the ESM2022 outputs, which
was reflected in the upstream `ng-packagr` implementation. Due to this
change, the libraries' build time and memory increased compared to the
previous versions.

## Expected Behavior

The `ng-packagr-lite` executor should only produce ESM2022 outputs and
avoid running an extra step to bundle the outputs to produce the
FESM2022.

Given the `ng-packagr-lite` executor is not meant to produce publishable
artifacts, its output doesn't need to strictly comply with the APF and
can focus more on build performance.

## Related Issue(s)

Fixes #29519
This commit is contained in:
Leosvel Pérez Espinosa 2025-01-15 10:04:43 +01:00 committed by GitHub
parent 0ae8665a88
commit f24a869b67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 93 additions and 484 deletions

View File

@ -174,10 +174,13 @@ describe('Tailwind support', () => {
const assertLibComponentStyles = (
lib: string,
libSpacing: (typeof spacing)['root']
libSpacing: (typeof spacing)['root'],
isPublishable: boolean = true
) => {
const builtComponentContent = readFile(
`dist/${lib}/fesm2022/${project}-${lib}.mjs`
isPublishable
? `dist/${lib}/fesm2022/${project}-${lib}.mjs`
: `dist/${lib}/esm2022/lib/foo.component.mjs`
);
let expectedStylesRegex = new RegExp(
`styles: \\[\\"\\.custom\\-btn(\\[_ngcontent\\-%COMP%\\])?{margin:${libSpacing.md};padding:${libSpacing.sm}}(\\\\n)?\\"\\]`
@ -203,7 +206,8 @@ describe('Tailwind support', () => {
assertLibComponentStyles(
buildLibWithTailwind.name,
spacing.projectVariant1
spacing.projectVariant1,
false
);
});
@ -223,7 +227,11 @@ describe('Tailwind support', () => {
runCLI(`build ${buildLibSetupTailwind}`);
assertLibComponentStyles(buildLibSetupTailwind, spacing.projectVariant2);
assertLibComponentStyles(
buildLibSetupTailwind,
spacing.projectVariant2,
false
);
});
it('should correctly build a buildable library with a tailwind.config.js file in the project root or workspace root', () => {
@ -241,7 +249,8 @@ describe('Tailwind support', () => {
assertLibComponentStyles(
buildLibNoProjectConfig,
spacing.projectVariant3
spacing.projectVariant3,
false
);
// remove tailwind.config.js file from the project root to test the one in the workspace root
@ -249,7 +258,7 @@ describe('Tailwind support', () => {
runCLI(`build ${buildLibNoProjectConfig}`);
assertLibComponentStyles(buildLibNoProjectConfig, spacing.root);
assertLibComponentStyles(buildLibNoProjectConfig, spacing.root, false);
});
it('should generate a publishable library with tailwind and build correctly', () => {

View File

@ -0,0 +1,18 @@
import {
type DestinationFiles,
NgEntryPoint as NgEntryPointBase,
} from 'ng-packagr/lib/ng-package/entry-point/entry-point';
import { dirname } from 'node:path';
export class NgEntryPoint extends NgEntryPointBase {
/**
* Point the FESM2022 files to the ESM2022 files.
*/
public override get destinationFiles(): DestinationFiles {
const result = super.destinationFiles;
result.fesm2022 = result.esm2022;
result.fesm2022Dir = dirname(result.esm2022);
return result;
}
}

View File

@ -0,0 +1,58 @@
/**
* Adapted from the original ng-packagr.
*
* Changes made:
* - Removed bundling altogether.
* - Write the ESM2022 outputs to the file system.
* - Fake the FESM2022 outputs pointing them to the ESM2022 outputs.
*/
import { BuildGraph } from 'ng-packagr/lib/graph/build-graph';
import { transformFromPromise } from 'ng-packagr/lib/graph/transform';
import type { NgEntryPoint as NgEntryPointBase } from 'ng-packagr/lib/ng-package/entry-point/entry-point';
import { isEntryPoint, isPackage } from 'ng-packagr/lib/ng-package/nodes';
import type { NgPackagrOptions } from 'ng-packagr/lib/ng-package/options.di';
import { NgPackage } from 'ng-packagr/lib/ng-package/package';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { NgEntryPoint } from './entry-point';
export const writeBundlesTransform = (_options: NgPackagrOptions) =>
transformFromPromise(async (graph) => {
const updatedGraph = new BuildGraph();
for (const entry of graph.entries()) {
if (isEntryPoint(entry)) {
const entryPoint = toCustomNgEntryPoint(entry.data.entryPoint);
entry.data.entryPoint = entryPoint;
entry.data.destinationFiles = entryPoint.destinationFiles;
for (const [path, outputCache] of entry.cache.outputCache.entries()) {
// write the outputs to the file system
await mkdir(dirname(path), { recursive: true });
await writeFile(path, outputCache.content);
}
} else if (isPackage(entry)) {
entry.data = new NgPackage(
entry.data.src,
toCustomNgEntryPoint(entry.data.primary),
entry.data.secondaries.map((secondary) =>
toCustomNgEntryPoint(secondary)
)
);
}
updatedGraph.put(entry);
}
return updatedGraph;
});
function toCustomNgEntryPoint(entryPoint: NgEntryPointBase): NgEntryPoint {
return new NgEntryPoint(
entryPoint.packageJson,
entryPoint.ngPackageJson,
entryPoint.basePath,
// @ts-expect-error this is a TS private property, but it can be accessed at runtime
entryPoint.secondaryData
);
}

View File

@ -1,35 +1,15 @@
import { NgPackagr, ngPackagr } from 'ng-packagr';
import { getInstalledAngularVersionInfo } from '../../utilities/angular-version-utils';
export async function getNgPackagrInstance(): Promise<NgPackagr> {
const { major: angularMajorVersion } = getInstalledAngularVersionInfo();
if (angularMajorVersion >= 19) {
const { STYLESHEET_PROCESSOR } = await import(
'../../utilities/ng-packagr/stylesheet-processor.di.js'
);
const packagr = ngPackagr();
packagr.withProviders([STYLESHEET_PROCESSOR]);
return packagr;
}
const { WRITE_BUNDLES_TRANSFORM } = await import(
'./pre-v19/ng-package/entry-point/write-bundles.di.js'
);
const { WRITE_PACKAGE_TRANSFORM } = await import(
'./pre-v19/ng-package/entry-point/write-package.di.js'
'./ng-package/entry-point/write-bundles.di.js'
);
const { STYLESHEET_PROCESSOR } = await import(
'../../utilities/ng-packagr/stylesheet-processor.di.js'
);
const packagr = ngPackagr();
packagr.withProviders([
WRITE_BUNDLES_TRANSFORM,
WRITE_PACKAGE_TRANSFORM,
STYLESHEET_PROCESSOR,
]);
packagr.withProviders([WRITE_BUNDLES_TRANSFORM, STYLESHEET_PROCESSOR]);
return packagr;
}

View File

@ -1,12 +0,0 @@
/**
* Adapted from the original ng-packagr.
*
* Changes made:
* - Removed bundling altogether.
*/
import { transformFromPromise } from 'ng-packagr/lib/graph/transform';
import { NgPackagrOptions } from 'ng-packagr/lib/ng-package/options.di';
export const writeBundlesTransform = (_options: NgPackagrOptions) =>
transformFromPromise(async (graph) => graph);

View File

@ -1,20 +0,0 @@
/**
* Adapted from the original ng-packagr source.
*
* Changes made:
* - Provide our own writePackageTransform function.
*/
import {
provideTransform,
TransformProvider,
} from 'ng-packagr/lib/graph/transform.di';
import { WRITE_PACKAGE_TRANSFORM_TOKEN } from 'ng-packagr/lib/ng-package/entry-point/write-package.di';
import { OPTIONS_TOKEN } from 'ng-packagr/lib/ng-package/options.di';
import { nxWritePackageTransform } from './write-package.transform';
export const WRITE_PACKAGE_TRANSFORM: TransformProvider = provideTransform({
provide: WRITE_PACKAGE_TRANSFORM_TOKEN,
useFactory: nxWritePackageTransform,
deps: [OPTIONS_TOKEN],
});

View File

@ -1,424 +0,0 @@
/**
* Adapted from the original ng-packagr.
*
* Changes made:
* - Change the package.json metadata to only use the ESM2022 output.
*/
import { logger } from '@nx/devkit';
import { BuildGraph } from 'ng-packagr/lib/graph/build-graph';
import { Node } from 'ng-packagr/lib/graph/node';
import { transformFromPromise } from 'ng-packagr/lib/graph/transform';
import { NgEntryPoint } from 'ng-packagr/lib/ng-package/entry-point/entry-point';
import {
EntryPointNode,
fileUrl,
isEntryPointInProgress,
isEntryPoint,
isPackage,
PackageNode,
} from 'ng-packagr/lib/ng-package/nodes';
import { NgPackagrOptions } from 'ng-packagr/lib/ng-package/options.di';
import { NgPackage } from 'ng-packagr/lib/ng-package/package';
import {
copyFile,
exists,
readFile,
rmdir,
stat,
writeFile,
} from 'ng-packagr/lib/utils/fs';
import { globFiles } from 'ng-packagr/lib/utils/glob';
import { ensureUnixPath } from 'ng-packagr/lib/utils/path';
import { AssetPattern } from 'ng-packagr/ng-package.schema';
import * as path from 'path';
export const nxWritePackageTransform = (options: NgPackagrOptions) =>
transformFromPromise(async (graph) => {
const entryPoint: EntryPointNode = graph.find(isEntryPointInProgress());
const ngEntryPoint: NgEntryPoint = entryPoint.data.entryPoint;
const ngPackageNode: PackageNode = graph.find(isPackage);
const ngPackage = ngPackageNode.data;
const { destinationFiles } = entryPoint.data;
if (!ngEntryPoint.isSecondaryEntryPoint) {
logger.log('Copying assets');
try {
await copyAssets(graph, entryPoint, ngPackageNode);
} catch (error) {
throw error;
}
}
// 6. WRITE PACKAGE.JSON
const relativeUnixFromDestPath = (filePath: string) =>
ensureUnixPath(path.relative(ngEntryPoint.destinationPath, filePath));
if (!ngEntryPoint.isSecondaryEntryPoint) {
try {
logger.info('Writing package manifest');
if (!options.watch) {
const primary = ngPackageNode.data.primary;
await writeFile(
path.join(primary.destinationPath, '.npmignore'),
`# Nested package.json's are only needed for development.\n**/package.json`
);
}
await writePackageJson(
ngEntryPoint,
ngPackage,
{
module: relativeUnixFromDestPath(destinationFiles.esm2022),
typings: relativeUnixFromDestPath(destinationFiles.declarations),
exports: generatePackageExports(ngEntryPoint, graph),
// webpack v4+ specific flag to enable advanced optimizations and code splitting
sideEffects: ngEntryPoint.packageJson.sideEffects ?? false,
},
!!options.watch
);
} catch (error) {
throw error;
}
} else if (ngEntryPoint.isSecondaryEntryPoint) {
if (options.watch) {
// Update the watch version of the primary entry point `package.json` file.
// this is needed because of Webpack's 5 `cachemanagedpaths`
// https://github.com/ng-packagr/ng-packagr/issues/2069
const primary = ngPackageNode.data.primary;
const packageJsonPath = path.join(
primary.destinationPath,
'package.json'
);
if (await exists(packageJsonPath)) {
const packageJson = JSON.parse(
await readFile(packageJsonPath, { encoding: 'utf8' })
);
packageJson.version = generateWatchVersion();
await writeFile(
path.join(primary.destinationPath, 'package.json'),
JSON.stringify(packageJson, undefined, 2)
);
}
}
// Write a package.json in each secondary entry-point
// This is need for esbuild to secondary entry-points in dist correctly.
await writeFile(
path.join(ngEntryPoint.destinationPath, 'package.json'),
JSON.stringify(
{ module: relativeUnixFromDestPath(destinationFiles.esm2022) },
undefined,
2
)
);
}
logger.info(`Built ${ngEntryPoint.moduleId}`);
return graph;
});
type AssetEntry = Exclude<AssetPattern, string>;
async function copyAssets(
graph: BuildGraph,
entryPointNode: EntryPointNode,
ngPackageNode: PackageNode
): Promise<void> {
const ngPackage = ngPackageNode.data;
const globsForceIgnored: string[] = [
'.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
`${ngPackage.dest}/**`,
];
const assets: AssetEntry[] = [];
for (const assetPath of ngPackage.assets) {
let asset: AssetEntry;
if (typeof assetPath === 'object') {
asset = { ...assetPath };
} else {
const [isDir, isFile] = await stat(path.join(ngPackage.src, assetPath))
.then((stats) => [stats.isDirectory(), stats.isFile()])
.catch(() => [false, false]);
if (isDir) {
asset = { glob: '**/*', input: assetPath, output: assetPath };
} else if (isFile) {
// filenames are their own glob
asset = {
glob: path.basename(assetPath),
input: path.dirname(assetPath),
output: path.dirname(assetPath),
};
} else {
asset = { glob: assetPath, input: '/', output: '/' };
}
}
asset.input = path.join(ngPackage.src, asset.input);
asset.output = path.join(ngPackage.dest, asset.output);
const isAncestorPath = (target: string, datum: string) =>
path.relative(datum, target).startsWith('..');
if (isAncestorPath(asset.input, ngPackage.src)) {
throw new Error(
'Cannot read assets from a location outside of the project root.'
);
}
if (isAncestorPath(asset.output, ngPackage.dest)) {
throw new Error(
'Cannot write assets to a location outside of the output path.'
);
}
assets.push(asset);
}
for (const asset of assets) {
const filePaths = await globFiles(asset.glob, {
cwd: asset.input,
ignore: [...(asset.ignore ?? []), ...globsForceIgnored],
dot: true,
onlyFiles: true,
followSymbolicLinks: asset.followSymlinks,
});
for (const filePath of filePaths) {
const fileSrcFullPath = path.join(asset.input, filePath);
const fileDestFullPath = path.join(asset.output, filePath);
const nodeUri = fileUrl(ensureUnixPath(fileSrcFullPath));
let node = graph.get(nodeUri);
if (!node) {
node = new Node(nodeUri);
graph.put(node);
}
entryPointNode.dependsOn(node);
await copyFile(fileSrcFullPath, fileDestFullPath);
}
}
}
/**
* Creates and writes a `package.json` file of the entry point used by the `node_module`
* resolution strategies.
*
* #### Example
*
* A consumer of the entry point depends on it by `import {..} from '@my/module/id';`.
* The module id `@my/module/id` will be resolved to the `package.json` file that is written by
* this build step.
* The properties `main`, `module`, `typings` (and so on) in the `package.json` point to the
* flattened JavaScript bundles, type definitions, (...).
*
* @param entryPoint An entry point of an Angular package / library
* @param additionalProperties Additional properties, e.g. binary artefacts (bundle files), to merge into `package.json`
*/
async function writePackageJson(
entryPoint: NgEntryPoint,
pkg: NgPackage,
additionalProperties: {
[key: string]: string | boolean | string[] | ConditionalExport;
},
isWatchMode: boolean
): Promise<void> {
// set additional properties
const packageJson = { ...entryPoint.packageJson, ...additionalProperties };
// read tslib version from `@angular/compiler` so that our tslib
// version at least matches that of angular if we use require('tslib').version
// it will get what installed and not the minimum version nor if it is a `~` or `^`
// this is only required for primary
if (isWatchMode) {
// Needed because of Webpack's 5 `cachemanagedpaths`
// https://github.com/angular/angular-cli/issues/20962
packageJson.version = generateWatchVersion();
}
if (
!packageJson.peerDependencies?.tslib &&
!packageJson.dependencies?.tslib
) {
const {
peerDependencies: angularPeerDependencies = {},
dependencies: angularDependencies = {},
} = require('@angular/compiler/package.json');
const tsLibVersion =
angularPeerDependencies.tslib || angularDependencies.tslib;
if (tsLibVersion) {
packageJson.dependencies = {
...packageJson.dependencies,
tslib: tsLibVersion,
};
}
} else if (packageJson.peerDependencies?.tslib) {
logger.warn(
`'tslib' is no longer recommended to be used as a 'peerDependencies'. Moving it to 'dependencies'.`
);
packageJson.dependencies = {
...(packageJson.dependencies || {}),
tslib: packageJson.peerDependencies.tslib,
};
delete packageJson.peerDependencies.tslib;
}
// Verify non-peerDependencies as they can easily lead to duplicate installs or version conflicts
// in the node_modules folder of an application
const allowedList = pkg.allowedNonPeerDependencies.map(
(value) => new RegExp(value)
);
try {
checkNonPeerDependencies(packageJson, 'dependencies', allowedList);
} catch (e) {
await rmdir(entryPoint.destinationPath, { recursive: true });
throw e;
}
// Removes scripts from package.json after build
if (packageJson.scripts) {
if (pkg.keepLifecycleScripts !== true) {
logger.info(
`Removing scripts section in package.json as it's considered a potential security vulnerability.`
);
delete packageJson.scripts;
} else {
logger.warn(
`You enabled keepLifecycleScripts explicitly. The scripts section in package.json will be published to npm.`
);
}
}
// keep the dist package.json clean
// this will not throw if ngPackage field does not exist
delete packageJson.ngPackage;
const packageJsonPropertiesToDelete = [
'stylelint',
'prettier',
'browserslist',
'devDependencies',
'jest',
'workspaces',
'husky',
];
for (const prop of packageJsonPropertiesToDelete) {
if (prop in packageJson) {
delete packageJson[prop];
logger.info(`Removing ${prop} section in package.json.`);
}
}
packageJson.name = entryPoint.moduleId;
await writeFile(
path.join(entryPoint.destinationPath, 'package.json'),
JSON.stringify(packageJson, undefined, 2)
);
}
function checkNonPeerDependencies(
packageJson: Record<string, unknown>,
property: string,
allowed: RegExp[]
) {
if (!packageJson[property]) {
return;
}
for (const dep of Object.keys(packageJson[property])) {
if (!allowed.some((regex) => regex.test(dep))) {
logger.warn(
`Distributing npm packages with '${property}' is not recommended. Please consider adding ${dep} to 'peerDependencies' or remove it from '${property}'.`
);
throw new Error(
`Dependency ${dep} must be explicitly allowed using the "allowedNonPeerDependencies" option.`
);
}
}
}
type PackageExports = Record<string, ConditionalExport>;
/**
* Type describing the conditional exports descriptor for an entry-point.
* https://nodejs.org/api/packages.html#packages_conditional_exports
*/
type ConditionalExport = {
types?: string;
esm2022?: string;
esm?: string;
default?: string;
};
/**
* Generates the `package.json` package exports following APF v13.
* This is supposed to match with: https://github.com/angular/angular/blob/e0667efa6eada64d1fb8b143840689090fc82e52/packages/bazel/src/ng_package/packager.ts#L415.
*/
function generatePackageExports(
{ destinationPath, packageJson }: NgEntryPoint,
graph: BuildGraph
): PackageExports {
const exports: PackageExports = packageJson.exports
? JSON.parse(JSON.stringify(packageJson.exports))
: {};
const insertMappingOrError = (
subpath: string,
mapping: ConditionalExport
) => {
exports[subpath] ??= {};
const subpathExport = exports[subpath];
// Go through all conditions that should be inserted. If the condition is already
// manually set of the subpath export, we throw an error. In general, we allow for
// additional conditions to be set. These will always precede the generated ones.
for (const conditionName of Object.keys(mapping)) {
if (subpathExport[conditionName] !== undefined) {
logger.warn(
`Found a conflicting export condition for "${subpath}". The "${conditionName}" ` +
`condition would be overridden by ng-packagr. Please unset it.`
);
}
// **Note**: The order of the conditions is preserved even though we are setting
// the conditions once at a time (the latest assignment will be at the end).
subpathExport[conditionName] = mapping[conditionName];
}
};
const relativeUnixFromDestPath = (filePath: string) =>
'./' + ensureUnixPath(path.relative(destinationPath, filePath));
insertMappingOrError('./package.json', { default: './package.json' });
const entryPoints = graph.filter(isEntryPoint);
for (const entryPoint of entryPoints) {
const { destinationFiles, isSecondaryEntryPoint } =
entryPoint.data.entryPoint;
const subpath = isSecondaryEntryPoint
? `./${destinationFiles.directory}`
: '.';
insertMappingOrError(subpath, {
types: relativeUnixFromDestPath(destinationFiles.declarations),
esm2022: relativeUnixFromDestPath(destinationFiles.esm2022),
esm: relativeUnixFromDestPath(destinationFiles.esm2022),
default: relativeUnixFromDestPath(destinationFiles.esm2022),
});
}
return exports;
}
/**
* Generates a new version for the package `package.json` when runing in watch mode.
*/
function generateWatchVersion() {
return `0.0.0-watch+${Date.now()}`;
}