Skip to content

Commit 60dcf28

Browse files
committed
fix(@angular/build): conditionally declare tslib in built library package
Inspects Rolldown output chunk imports to detect whether any generated library bundle imports `tslib` (for example, when custom decorators or explicit resource management helpers are emitted). When `tslib` is imported and not already declared in the library's `package.json` `dependencies`, resolves the `tslib` version range from `@angular/compiler` (with fallback to `tslib/package.json`) and adds it to the emitted `package.json`. Closes #34131
1 parent aee35d4 commit 60dcf28

10 files changed

Lines changed: 311 additions & 45 deletions

File tree

‎packages/angular/build/src/builders/library/builder.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
1010
import type { logging } from '@angular-devkit/core';
11-
import fs from 'node:fs/promises';
1211
import {
1312
resetSassWorkerPoolCaches,
1413
shutdownSassWorkerPool,
@@ -27,8 +26,9 @@ import type { BuildWatcher } from '../../utils/watcher';
2726
import { normalizeLibraryOptions } from './options';
2827
import { updateWatchedEntryPoints } from './pipeline/entry-points';
2928
import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler';
29+
import { loadPackageJson } from './pipeline/utils';
3030
import type { Schema as LibraryBuilderOptions } from './schema';
31-
import type { NormalizedLibraryOptions, PackageJsonData, SingleBuildState } from './types';
31+
import type { NormalizedLibraryOptions, SingleBuildState } from './types';
3232

3333
/**
3434
* Executes the library builder to compile, bundle, and package an Angular library into the Angular Package Format (APF).
@@ -340,15 +340,6 @@ async function* runWatchLoop(
340340
}
341341
}
342342

343-
/**
344-
* Loads and parses a JSON file from disk.
345-
*/
346-
async function loadPackageJson(packageJsonPath: string): Promise<PackageJsonData> {
347-
const content = await fs.readFile(packageJsonPath, 'utf-8');
348-
349-
return JSON.parse(content) as PackageJsonData;
350-
}
351-
352343
/**
353344
* Logs the build completion time and status.
354345
*/

‎packages/angular/build/src/builders/library/options.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import type { BuilderContext } from '@angular-devkit/architect';
10-
import fs from 'node:fs/promises';
1110
import path from 'node:path';
1211
import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory';
1312
import { normalizeAssetPatterns } from '../../utils';
@@ -21,6 +20,7 @@ import {
2120
} from '../../utils/postcss-configuration';
2221
import { getProjectRootPaths } from '../../utils/project-metadata';
2322
import { normalizeEntryPoints } from './pipeline/entry-points';
23+
import { loadPackageJson } from './pipeline/utils';
2424
import type { Schema as LibraryBuilderOptions } from './schema';
2525
import type { NormalizedLibraryOptions, PackageJsonData } from './types';
2626

@@ -55,8 +55,7 @@ export async function normalizeLibraryOptions(
5555

5656
let packageJson: PackageJsonData;
5757
try {
58-
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8');
59-
packageJson = JSON.parse(packageJsonContent) as PackageJsonData;
58+
packageJson = await loadPackageJson(packageJsonPath);
6059
} catch (error) {
6160
assertIsError(error);
6261
throw new Error(`Failed to read 'package.json' at '${packageJsonPath}': ${error.message}`, {

‎packages/angular/build/src/builders/library/pipeline/build-action.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,16 @@ export async function buildAction(actionContext: BuildActionContext): Promise<vo
193193
}
194194
}
195195

196-
if (shouldGenerateManifests) {
197-
filesToEmit.push(...generatePackageManifests(options, isWatchMode));
196+
let hasTslibImport = false;
197+
for (const entryPoint of options.entryPoints.values()) {
198+
if (buildState.previousBundleResults.get(entryPoint.name)?.hasTslibImport) {
199+
hasTslibImport = true;
200+
break;
201+
}
202+
}
203+
204+
if (shouldGenerateManifests || buildState.hasEmittedTslib !== hasTslibImport) {
205+
filesToEmit.push(...(await generatePackageManifests(options, isWatchMode, hasTslibImport)));
198206
}
199207

200208
const resolvedAssetsToEmit = (actionContext.assetsToEmit ??= await collectAssetsToEmit(
@@ -220,6 +228,7 @@ export async function buildAction(actionContext: BuildActionContext): Promise<vo
220228
});
221229

222230
buildState.hasEmittedManifests = true;
231+
buildState.hasEmittedTslib = hasTslibImport;
223232
buildState.hasEmittedAssets = true;
224233
}
225234

‎packages/angular/build/src/builders/library/pipeline/bundler.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ interface MultiBundleOutput {
6969
/** Map of bundle entry names to the set of virtual module IDs included in the bundle. */
7070
moduleIdsByBundle: Map<string, Set<string>>;
7171

72+
/** Set of bundle entry names that import 'tslib'. */
73+
tslibBundles: Set<string>;
74+
7275
/** Warning messages emitted during the bundle invocation. */
7376
warnings: string[];
7477
}
@@ -113,15 +116,16 @@ export async function bundleEntryPoints(
113116

114117
for (const { entryPoint, previousBundleResult } of items) {
115118
const { bundleName, name } = entryPoint;
119+
const esmModuleIds = esmOutput.moduleIdsByBundle.get(bundleName);
116120
bundleResults.set(name, {
117-
esmModuleIds:
118-
esmOutput.moduleIdsByBundle.get(bundleName) ??
119-
previousBundleResult?.esmModuleIds ??
120-
new Set(),
121+
esmModuleIds: esmModuleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
121122
dtsModuleIds:
122123
dtsOutput.moduleIdsByBundle.get(bundleName) ??
123124
previousBundleResult?.dtsModuleIds ??
124125
new Set(),
126+
hasTslibImport: esmModuleIds
127+
? esmOutput.tslibBundles.has(bundleName)
128+
: (previousBundleResult?.hasTslibImport ?? false),
125129
});
126130
}
127131

@@ -238,9 +242,10 @@ function createMemoryFileLoaderPlugin(
238242
function processRolldownOutput(
239243
output: RolldownOutput['output'],
240244
dir: string,
241-
): Pick<MultiBundleOutput, 'filesToEmit' | 'moduleIdsByBundle'> {
245+
): Omit<MultiBundleOutput, 'warnings'> {
242246
const filesToEmit: MemoryOutputFile[] = [];
243247
const moduleIdsByBundle = new Map<string, Set<string>>();
248+
const tslibBundles = new Set<string>();
244249
const chunksByFileName = new Map<string, OutputChunk>();
245250
const entryChunks: OutputChunk[] = [];
246251

@@ -286,6 +291,10 @@ function processRolldownOutput(
286291
}
287292

288293
for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) {
294+
if (depFile === 'tslib') {
295+
tslibBundles.add(entryChunk.name);
296+
}
297+
289298
const depChunk = chunksByFileName.get(depFile);
290299
if (depChunk && !visited.has(depChunk)) {
291300
queue.push(depChunk);
@@ -294,7 +303,7 @@ function processRolldownOutput(
294303
}
295304
}
296305

297-
return { filesToEmit, moduleIdsByBundle };
306+
return { filesToEmit, moduleIdsByBundle, tslibBundles };
298307
}
299308

300309
/**
@@ -380,7 +389,12 @@ async function bundleAllEsm(
380389
options: NormalizedLibraryOptions,
381390
): Promise<MultiBundleOutput> {
382391
if (entryPoints.length === 0) {
383-
return { filesToEmit: [], moduleIdsByBundle: new Map(), warnings: [] };
392+
return {
393+
filesToEmit: [],
394+
moduleIdsByBundle: new Map(),
395+
tslibBundles: new Set(),
396+
warnings: [],
397+
};
384398
}
385399

386400
return executeMultiBundle(
@@ -405,7 +419,12 @@ async function bundleAllDts(
405419
options: NormalizedLibraryOptions,
406420
): Promise<MultiBundleOutput> {
407421
if (entryPoints.length === 0) {
408-
return { filesToEmit: [], moduleIdsByBundle: new Map(), warnings: [] };
422+
return {
423+
filesToEmit: [],
424+
moduleIdsByBundle: new Map(),
425+
tslibBundles: new Set(),
426+
warnings: [],
427+
};
409428
}
410429

411430
const dtsSourcemap = options.declarationMap;

‎packages/angular/build/src/builders/library/pipeline/package-manifests.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { createRequire } from 'node:module';
910
import path from 'node:path';
1011
import type { NormalizedLibraryOptions, PackageJsonData } from '../types';
1112
import {
1213
FESM_OUTPUT_DIR,
1314
type MemoryOutputFile,
1415
TYPES_OUTPUT_DIR,
1516
createMemoryOutputFile,
17+
loadPackageJson,
1618
} from './utils';
1719

1820
/**
1921
* Generates the APF package.json and secondary entry point package.json manifests.
2022
*
2123
* @param options The normalized library options.
2224
* @param isWatchMode Whether the builder is running in watch mode.
25+
* @param hasTslibImport Whether any output chunk imports 'tslib'.
2326
* @returns An array of memory output files containing generated package manifests and .npmignore.
2427
*/
25-
export function generatePackageManifests(
28+
export async function generatePackageManifests(
2629
options: NormalizedLibraryOptions,
2730
isWatchMode: boolean,
28-
): MemoryOutputFile[] {
31+
hasTslibImport = false,
32+
): Promise<MemoryOutputFile[]> {
2933
const { packageJson: rawPackageJson, keepLifecycleScripts, compilationMode } = options;
3034

3135
const {
@@ -73,6 +77,16 @@ export function generatePackageManifests(
7377
version: isWatchMode ? `0.0.0-watch+${Date.now()}` : version,
7478
};
7579

80+
if (hasTslibImport && !rawPackageJson.dependencies?.['tslib']) {
81+
const tslibVersion = await getAngularTslibRange(options.workspaceRoot);
82+
if (tslibVersion) {
83+
distPackageJson.dependencies = {
84+
...rawPackageJson.dependencies,
85+
tslib: tslibVersion,
86+
};
87+
}
88+
}
89+
7690
// Retain scripts if keepLifecycleScripts is set
7791
if (keepLifecycleScripts && scripts) {
7892
distPackageJson.scripts = scripts;
@@ -166,3 +180,46 @@ function createExportConditions(
166180
default: fesmPath,
167181
};
168182
}
183+
184+
/**
185+
* Cached tslib range used by the Angular compiler.
186+
*/
187+
let cachedTslibRange: string | undefined;
188+
189+
/**
190+
* Get the tslib range used by the Angular compiler.
191+
* @param workspaceRoot path to the workspace root
192+
* @returns tslib range
193+
*/
194+
async function getAngularTslibRange(workspaceRoot: string): Promise<string> {
195+
if (cachedTslibRange) {
196+
return cachedTslibRange;
197+
}
198+
199+
const workspaceRequire = createRequire(path.join(workspaceRoot, 'package.json'));
200+
201+
// Try resolving via @angular/compiler dependencies
202+
try {
203+
const angularCompilerPkg = await loadPackageJson(
204+
workspaceRequire.resolve('@angular/compiler/package.json'),
205+
);
206+
cachedTslibRange = angularCompilerPkg.dependencies?.['tslib'];
207+
} catch {}
208+
209+
// Fallback: Try resolving tslib directly
210+
if (!cachedTslibRange) {
211+
try {
212+
const tslibPkg = await loadPackageJson(workspaceRequire.resolve('tslib/package.json'));
213+
if (tslibPkg.version) {
214+
cachedTslibRange = `^${tslibPkg.version}`;
215+
}
216+
} catch {}
217+
}
218+
219+
// Fail fast if still unresolved
220+
if (!cachedTslibRange) {
221+
throw new Error('Unable to resolve tslib range.');
222+
}
223+
224+
return cachedTslibRange;
225+
}

0 commit comments

Comments
 (0)