Skip to content

Commit 94ec7a0

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 76dd17d commit 94ec7a0

7 files changed

Lines changed: 259 additions & 9 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,16 @@ export async function buildAction(actionContext: BuildActionContext): Promise<vo
166166
}
167167
}
168168

169-
if (shouldGenerateManifests) {
170-
filesToEmit.push(...generatePackageManifests(options, isWatchMode));
169+
let hasTslibImport = false;
170+
for (const entryPoint of options.entryPoints.values()) {
171+
if (buildState.previousBundleResults.get(entryPoint.name)?.hasTslibImport) {
172+
hasTslibImport = true;
173+
break;
174+
}
175+
}
176+
177+
if (shouldGenerateManifests || buildState.hasEmittedTslib !== hasTslibImport) {
178+
filesToEmit.push(...generatePackageManifests(options, isWatchMode, hasTslibImport));
171179
}
172180

173181
filesToEmit.push(
@@ -195,6 +203,7 @@ export async function buildAction(actionContext: BuildActionContext): Promise<vo
195203
});
196204

197205
buildState.hasEmittedManifests = true;
206+
buildState.hasEmittedTslib = hasTslibImport;
198207
buildState.hasEmittedAssets = true;
199208
}
200209

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ interface MultiBundleOutput {
6565

6666
/** Map of bundle entry names to the set of virtual module IDs included in the bundle. */
6767
moduleIdsByBundle: Map<string, Set<string>>;
68+
69+
/** Set of bundle entry names that import 'tslib'. */
70+
tslibBundles: Set<string>;
6871
}
6972

7073
/**
@@ -107,15 +110,16 @@ export async function bundleEntryPoints(
107110

108111
for (const { entryPoint, previousBundleResult } of items) {
109112
const { bundleName, name } = entryPoint;
113+
const esmModuleIds = esmOutput.moduleIdsByBundle.get(bundleName);
110114
bundleResults.set(name, {
111-
esmModuleIds:
112-
esmOutput.moduleIdsByBundle.get(bundleName) ??
113-
previousBundleResult?.esmModuleIds ??
114-
new Set(),
115+
esmModuleIds: esmModuleIds ?? previousBundleResult?.esmModuleIds ?? new Set(),
115116
dtsModuleIds:
116117
dtsOutput.moduleIdsByBundle.get(bundleName) ??
117118
previousBundleResult?.dtsModuleIds ??
118119
new Set(),
120+
hasTslibImport: esmModuleIds
121+
? esmOutput.tslibBundles.has(bundleName)
122+
: (previousBundleResult?.hasTslibImport ?? false),
119123
});
120124
}
121125

@@ -231,6 +235,7 @@ function createMemoryFileLoaderPlugin(
231235
function processRolldownOutput(output: RolldownOutput['output'], dir: string): MultiBundleOutput {
232236
const filesToEmit: MemoryOutputFile[] = [];
233237
const moduleIdsByBundle = new Map<string, Set<string>>();
238+
const tslibBundles = new Set<string>();
234239
const chunksByFileName = new Map<string, OutputChunk>();
235240
const entryChunks: OutputChunk[] = [];
236241

@@ -276,6 +281,10 @@ function processRolldownOutput(output: RolldownOutput['output'], dir: string): M
276281
}
277282

278283
for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) {
284+
if (depFile === 'tslib' || depFile.startsWith('tslib/')) {
285+
tslibBundles.add(entryChunk.name);
286+
}
287+
279288
const depChunk = chunksByFileName.get(depFile);
280289
if (depChunk && !visited.has(depChunk)) {
281290
queue.push(depChunk);
@@ -284,7 +293,7 @@ function processRolldownOutput(output: RolldownOutput['output'], dir: string): M
284293
}
285294
}
286295

287-
return { filesToEmit, moduleIdsByBundle };
296+
return { filesToEmit, moduleIdsByBundle, tslibBundles };
288297
}
289298

290299
/**
@@ -350,7 +359,7 @@ async function bundleAllEsm(
350359
options: NormalizedLibraryOptions,
351360
): Promise<MultiBundleOutput> {
352361
if (entryPoints.length === 0) {
353-
return { filesToEmit: [], moduleIdsByBundle: new Map() };
362+
return { filesToEmit: [], moduleIdsByBundle: new Map(), tslibBundles: new Set() };
354363
}
355364

356365
return executeMultiBundle(
@@ -376,7 +385,7 @@ async function bundleAllDts(
376385
options: NormalizedLibraryOptions,
377386
): Promise<MultiBundleOutput> {
378387
if (entryPoints.length === 0) {
379-
return { filesToEmit: [], moduleIdsByBundle: new Map() };
388+
return { filesToEmit: [], moduleIdsByBundle: new Map(), tslibBundles: new Set() };
380389
}
381390

382391
const dtsSourcemap = options.declarationMap;

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
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 {
@@ -20,11 +21,13 @@ import {
2021
*
2122
* @param options The normalized library options.
2223
* @param isWatchMode Whether the builder is running in watch mode.
24+
* @param hasTslibImport Whether any output chunk imports 'tslib'.
2325
* @returns An array of memory output files containing generated package manifests and .npmignore.
2426
*/
2527
export function generatePackageManifests(
2628
options: NormalizedLibraryOptions,
2729
isWatchMode: boolean,
30+
hasTslibImport = false,
2831
): MemoryOutputFile[] {
2932
const { packageJson: rawPackageJson, keepLifecycleScripts, compilationMode } = options;
3033

@@ -73,6 +76,16 @@ export function generatePackageManifests(
7376
version: isWatchMode ? `0.0.0-watch+${Date.now()}` : version,
7477
};
7578

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

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,42 @@ describe('generatePackageManifests', () => {
307307
);
308308
expect(getRootPackageJson(files2).sideEffects).toEqual(['*.css']);
309309
});
310+
311+
it('should conditionally inject tslib into dependencies only when hasTslibImport is true', () => {
312+
const options = createOptions({
313+
workspaceRoot: process.cwd(),
314+
packageJson: {
315+
name: 'my-lib',
316+
version: '1.0.0',
317+
},
318+
});
319+
320+
const filesWithoutTslib = generatePackageManifests(options, false, false);
321+
expect(getRootPackageJson(filesWithoutTslib).dependencies).toBeUndefined();
322+
323+
const filesWithTslib = generatePackageManifests(options, false, true);
324+
expect(getRootPackageJson(filesWithTslib).dependencies).toEqual({
325+
tslib: jasmine.any(String),
326+
});
327+
});
328+
329+
it('should preserve existing user tslib declaration when hasTslibImport is true', () => {
330+
const filesInDeps = generatePackageManifests(
331+
createOptions({
332+
workspaceRoot: process.cwd(),
333+
packageJson: {
334+
name: 'my-lib',
335+
version: '1.0.0',
336+
dependencies: {
337+
tslib: '^2.0.0',
338+
},
339+
},
340+
}),
341+
false,
342+
true,
343+
);
344+
expect(getRootPackageJson(filesInDeps).dependencies).toEqual({
345+
tslib: '^2.0.0',
346+
});
347+
});
310348
});

‎packages/angular/build/src/builders/library/tests/behavior/build_spec.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
3232

3333
harness.expectFile('dist/lib/package.json').toExist();
3434
const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json'));
35+
expect(pkgJson.dependencies).toBeUndefined();
3536
expect(pkgJson).toEqual(
3637
jasmine.objectContaining({
3738
name: 'lib',
@@ -47,5 +48,71 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
4748
}),
4849
);
4950
});
51+
52+
it('should automatically add tslib to dependencies when output chunks import tslib', async () => {
53+
await harness.writeFile(
54+
'projects/lib/src/public-api.ts',
55+
`
56+
function CustomClassDecorator(): ClassDecorator {
57+
return () => {};
58+
}
59+
60+
@CustomClassDecorator()
61+
export class DecoratedService {}
62+
`,
63+
);
64+
65+
harness.useTarget('build', {
66+
...BASE_OPTIONS,
67+
});
68+
69+
const { result } = await harness.executeOnce();
70+
expect(result?.error).toBeUndefined();
71+
expect(result?.success).toBeTrue();
72+
73+
const fesmContent = harness.readFile('dist/lib/fesm2022/lib.mjs');
74+
expect(fesmContent).toContain('tslib');
75+
76+
const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json'));
77+
expect(pkgJson.dependencies).toEqual({
78+
tslib: jasmine.any(String),
79+
});
80+
});
81+
82+
it('should preserve existing user tslib version in package.json when output chunks import tslib', async () => {
83+
await harness.writeFile(
84+
'projects/lib/src/public-api.ts',
85+
`
86+
function CustomClassDecorator(): ClassDecorator {
87+
return () => {};
88+
}
89+
90+
@CustomClassDecorator()
91+
export class DecoratedService {}
92+
`,
93+
);
94+
95+
await harness.modifyFile('projects/lib/package.json', (content) => {
96+
const pkg = JSON.parse(content);
97+
pkg.dependencies = {
98+
tslib: '^2.0.0',
99+
};
100+
101+
return JSON.stringify(pkg, null, 2);
102+
});
103+
104+
harness.useTarget('build', {
105+
...BASE_OPTIONS,
106+
});
107+
108+
const { result } = await harness.executeOnce();
109+
expect(result?.error).toBeUndefined();
110+
expect(result?.success).toBeTrue();
111+
112+
const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json'));
113+
expect(pkgJson.dependencies).toEqual({
114+
tslib: '^2.0.0',
115+
});
116+
});
50117
});
51118
});

‎packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,5 +515,53 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
515515
},
516516
]);
517517
});
518+
519+
it('should update package.json in watch mode when tslib import is added or removed', async () => {
520+
harness.useTarget('build', {
521+
...BASE_OPTIONS,
522+
watch: true,
523+
});
524+
525+
await harness.executeWithCases([
526+
async ({ result }) => {
527+
expect(result?.success).toBeTrue();
528+
const pkg = JSON.parse(harness.readFile('dist/lib/package.json'));
529+
expect(pkg.dependencies).toBeUndefined();
530+
531+
// Add a custom decorator that causes tslib to be imported
532+
await harness.writeFile(
533+
'projects/lib/src/public-api.ts',
534+
`
535+
function CustomClassDecorator(): ClassDecorator {
536+
return () => {};
537+
}
538+
539+
@CustomClassDecorator()
540+
export class DecoratedService {}
541+
`,
542+
);
543+
},
544+
async ({ result }) => {
545+
expect(result?.success).toBeTrue();
546+
const pkg = JSON.parse(harness.readFile('dist/lib/package.json'));
547+
expect(pkg.dependencies).toEqual({
548+
tslib: jasmine.any(String),
549+
});
550+
551+
// Remove the custom decorator so tslib is no longer imported
552+
await harness.writeFile(
553+
'projects/lib/src/public-api.ts',
554+
`
555+
export class PlainService {}
556+
`,
557+
);
558+
},
559+
async ({ result }) => {
560+
expect(result?.success).toBeTrue();
561+
const pkg = JSON.parse(harness.readFile('dist/lib/package.json'));
562+
expect(pkg.dependencies).toBeUndefined();
563+
},
564+
]);
565+
});
518566
});
519567
});

0 commit comments

Comments
 (0)