From 60dcf2859038cdb6e02f775fc2b471a8472c615e Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:16:37 +0000 Subject: [PATCH] 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 --- .../build/src/builders/library/builder.ts | 13 +--- .../build/src/builders/library/options.ts | 5 +- .../builders/library/pipeline/build-action.ts | 13 +++- .../src/builders/library/pipeline/bundler.ts | 35 +++++++-- .../library/pipeline/package-manifests.ts | 61 ++++++++++++++- .../pipeline/package-manifests_spec.ts | 76 ++++++++++++++----- .../src/builders/library/pipeline/utils.ts | 15 ++++ .../library/tests/behavior/build_spec.ts | 67 ++++++++++++++++ .../library/tests/behavior/watch_spec.ts | 48 ++++++++++++ .../build/src/builders/library/types.ts | 23 ++++++ 10 files changed, 311 insertions(+), 45 deletions(-) diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index 67d5735d3f8c..33df771fc6ab 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -8,7 +8,6 @@ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import type { logging } from '@angular-devkit/core'; -import fs from 'node:fs/promises'; import { resetSassWorkerPoolCaches, shutdownSassWorkerPool, @@ -27,8 +26,9 @@ import type { BuildWatcher } from '../../utils/watcher'; import { normalizeLibraryOptions } from './options'; import { updateWatchedEntryPoints } from './pipeline/entry-points'; import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler'; +import { loadPackageJson } from './pipeline/utils'; import type { Schema as LibraryBuilderOptions } from './schema'; -import type { NormalizedLibraryOptions, PackageJsonData, SingleBuildState } from './types'; +import type { NormalizedLibraryOptions, SingleBuildState } from './types'; /** * 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( } } -/** - * Loads and parses a JSON file from disk. - */ -async function loadPackageJson(packageJsonPath: string): Promise { - const content = await fs.readFile(packageJsonPath, 'utf-8'); - - return JSON.parse(content) as PackageJsonData; -} - /** * Logs the build completion time and status. */ diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 877b4c45cf86..450dce36cab5 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -7,7 +7,6 @@ */ import type { BuilderContext } from '@angular-devkit/architect'; -import fs from 'node:fs/promises'; import path from 'node:path'; import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; import { normalizeAssetPatterns } from '../../utils'; @@ -21,6 +20,7 @@ import { } from '../../utils/postcss-configuration'; import { getProjectRootPaths } from '../../utils/project-metadata'; import { normalizeEntryPoints } from './pipeline/entry-points'; +import { loadPackageJson } from './pipeline/utils'; import type { Schema as LibraryBuilderOptions } from './schema'; import type { NormalizedLibraryOptions, PackageJsonData } from './types'; @@ -55,8 +55,7 @@ export async function normalizeLibraryOptions( let packageJson: PackageJsonData; try { - const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); - packageJson = JSON.parse(packageJsonContent) as PackageJsonData; + packageJson = await loadPackageJson(packageJsonPath); } catch (error) { assertIsError(error); throw new Error(`Failed to read 'package.json' at '${packageJsonPath}': ${error.message}`, { diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index f6e2d55af9ba..19718a793deb 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -193,8 +193,16 @@ export async function buildAction(actionContext: BuildActionContext): Promise>; + /** Set of bundle entry names that import 'tslib'. */ + tslibBundles: Set; + /** Warning messages emitted during the bundle invocation. */ warnings: string[]; } @@ -113,15 +116,16 @@ export async function bundleEntryPoints( for (const { entryPoint, previousBundleResult } of items) { const { bundleName, name } = entryPoint; + const esmModuleIds = esmOutput.moduleIdsByBundle.get(bundleName); bundleResults.set(name, { - esmModuleIds: - esmOutput.moduleIdsByBundle.get(bundleName) ?? - previousBundleResult?.esmModuleIds ?? - new Set(), + esmModuleIds: esmModuleIds ?? previousBundleResult?.esmModuleIds ?? new Set(), dtsModuleIds: dtsOutput.moduleIdsByBundle.get(bundleName) ?? previousBundleResult?.dtsModuleIds ?? new Set(), + hasTslibImport: esmModuleIds + ? esmOutput.tslibBundles.has(bundleName) + : (previousBundleResult?.hasTslibImport ?? false), }); } @@ -238,9 +242,10 @@ function createMemoryFileLoaderPlugin( function processRolldownOutput( output: RolldownOutput['output'], dir: string, -): Pick { +): Omit { const filesToEmit: MemoryOutputFile[] = []; const moduleIdsByBundle = new Map>(); + const tslibBundles = new Set(); const chunksByFileName = new Map(); const entryChunks: OutputChunk[] = []; @@ -286,6 +291,10 @@ function processRolldownOutput( } for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) { + if (depFile === 'tslib') { + tslibBundles.add(entryChunk.name); + } + const depChunk = chunksByFileName.get(depFile); if (depChunk && !visited.has(depChunk)) { queue.push(depChunk); @@ -294,7 +303,7 @@ function processRolldownOutput( } } - return { filesToEmit, moduleIdsByBundle }; + return { filesToEmit, moduleIdsByBundle, tslibBundles }; } /** @@ -380,7 +389,12 @@ async function bundleAllEsm( options: NormalizedLibraryOptions, ): Promise { if (entryPoints.length === 0) { - return { filesToEmit: [], moduleIdsByBundle: new Map(), warnings: [] }; + return { + filesToEmit: [], + moduleIdsByBundle: new Map(), + tslibBundles: new Set(), + warnings: [], + }; } return executeMultiBundle( @@ -405,7 +419,12 @@ async function bundleAllDts( options: NormalizedLibraryOptions, ): Promise { if (entryPoints.length === 0) { - return { filesToEmit: [], moduleIdsByBundle: new Map(), warnings: [] }; + return { + filesToEmit: [], + moduleIdsByBundle: new Map(), + tslibBundles: new Set(), + warnings: [], + }; } const dtsSourcemap = options.declarationMap; diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts index 8a3772e0e43e..90e5d5bbd94a 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { createRequire } from 'node:module'; import path from 'node:path'; import type { NormalizedLibraryOptions, PackageJsonData } from '../types'; import { @@ -13,6 +14,7 @@ import { type MemoryOutputFile, TYPES_OUTPUT_DIR, createMemoryOutputFile, + loadPackageJson, } from './utils'; /** @@ -20,12 +22,14 @@ import { * * @param options The normalized library options. * @param isWatchMode Whether the builder is running in watch mode. + * @param hasTslibImport Whether any output chunk imports 'tslib'. * @returns An array of memory output files containing generated package manifests and .npmignore. */ -export function generatePackageManifests( +export async function generatePackageManifests( options: NormalizedLibraryOptions, isWatchMode: boolean, -): MemoryOutputFile[] { + hasTslibImport = false, +): Promise { const { packageJson: rawPackageJson, keepLifecycleScripts, compilationMode } = options; const { @@ -73,6 +77,16 @@ export function generatePackageManifests( version: isWatchMode ? `0.0.0-watch+${Date.now()}` : version, }; + if (hasTslibImport && !rawPackageJson.dependencies?.['tslib']) { + const tslibVersion = await getAngularTslibRange(options.workspaceRoot); + if (tslibVersion) { + distPackageJson.dependencies = { + ...rawPackageJson.dependencies, + tslib: tslibVersion, + }; + } + } + // Retain scripts if keepLifecycleScripts is set if (keepLifecycleScripts && scripts) { distPackageJson.scripts = scripts; @@ -166,3 +180,46 @@ function createExportConditions( default: fesmPath, }; } + +/** + * Cached tslib range used by the Angular compiler. + */ +let cachedTslibRange: string | undefined; + +/** + * Get the tslib range used by the Angular compiler. + * @param workspaceRoot path to the workspace root + * @returns tslib range + */ +async function getAngularTslibRange(workspaceRoot: string): Promise { + if (cachedTslibRange) { + return cachedTslibRange; + } + + const workspaceRequire = createRequire(path.join(workspaceRoot, 'package.json')); + + // Try resolving via @angular/compiler dependencies + try { + const angularCompilerPkg = await loadPackageJson( + workspaceRequire.resolve('@angular/compiler/package.json'), + ); + cachedTslibRange = angularCompilerPkg.dependencies?.['tslib']; + } catch {} + + // Fallback: Try resolving tslib directly + if (!cachedTslibRange) { + try { + const tslibPkg = await loadPackageJson(workspaceRequire.resolve('tslib/package.json')); + if (tslibPkg.version) { + cachedTslibRange = `^${tslibPkg.version}`; + } + } catch {} + } + + // Fail fast if still unresolved + if (!cachedTslibRange) { + throw new Error('Unable to resolve tslib range.'); + } + + return cachedTslibRange; +} diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts index 822af1d536f9..be58cbb3cb20 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts @@ -93,7 +93,7 @@ describe('generatePackageManifests', () => { }; } - it('should generate a valid APF package.json for an unscoped package', () => { + it('should generate a valid APF package.json for an unscoped package', async () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -108,7 +108,7 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result).toEqual({ @@ -131,7 +131,7 @@ describe('generatePackageManifests', () => { }); }); - it('should sanitize scoped package names in fesm and types paths', () => { + it('should sanitize scoped package names in fesm and types paths', async () => { const options = createOptions({ packageJson: { name: '@my-scope/my-lib', @@ -139,7 +139,7 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result).toEqual( @@ -158,7 +158,7 @@ describe('generatePackageManifests', () => { ); }); - it('should retain scripts when keepLifecycleScripts is true', () => { + it('should retain scripts when keepLifecycleScripts is true', async () => { const options = createOptions({ keepLifecycleScripts: true, packageJson: { @@ -170,12 +170,12 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.scripts).toEqual({ postinstall: 'echo done' }); }); - it('should configure secondary entry points and create secondary manifests', () => { + it('should configure secondary entry points and create secondary manifests', async () => { const options = createOptions( { packageJson: { @@ -186,7 +186,7 @@ describe('generatePackageManifests', () => { true, ); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.exports).toEqual( @@ -210,7 +210,7 @@ describe('generatePackageManifests', () => { expect(npmignoreFile?.contents).toContain('/testing/package.json'); }); - it('should inject watch version when isWatchMode is true', () => { + it('should inject watch version when isWatchMode is true', async () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -218,12 +218,12 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, true); + const files = await generatePackageManifests(options, true); const result = getRootPackageJson(files); expect(result.version).toMatch(/^0\.0\.0-watch\+\d+$/); }); - it('should throw an error if primary entry point is missing', () => { + it('should throw an error if primary entry point is missing', async () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -232,12 +232,12 @@ describe('generatePackageManifests', () => { entryPoints: new Map(), }); - expect(() => generatePackageManifests(options, false)).toThrowError( + await expectAsync(generatePackageManifests(options, false)).toBeRejectedWithError( /Primary entry point '\.' was not found in entryPoints\./, ); }); - it('should inject prepublishOnly guard script when compilationMode is full', () => { + it('should inject prepublishOnly guard script when compilationMode is full', async () => { const options = createOptions({ compilationMode: 'full', packageJson: { @@ -246,14 +246,14 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.scripts?.['prepublishOnly']).toContain( 'Trying to publish a package that has been compiled in full compilation mode', ); }); - it('should preserve custom user exports in package.json and merge subpath conditions', () => { + it('should preserve custom user exports in package.json and merge subpath conditions', async () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -268,7 +268,7 @@ describe('generatePackageManifests', () => { }, }); - const files = generatePackageManifests(options, false); + const files = await generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.exports).toEqual({ @@ -283,8 +283,8 @@ describe('generatePackageManifests', () => { }); }); - it('should default sideEffects to false if not specified, and preserve when set', () => { - const files1 = generatePackageManifests( + it('should default sideEffects to false if not specified, and preserve when set', async () => { + const files1 = await generatePackageManifests( createOptions({ packageJson: { name: 'my-lib', @@ -295,7 +295,7 @@ describe('generatePackageManifests', () => { ); expect(getRootPackageJson(files1).sideEffects).toBeFalse(); - const files2 = generatePackageManifests( + const files2 = await generatePackageManifests( createOptions({ packageJson: { name: 'my-lib', @@ -307,4 +307,42 @@ describe('generatePackageManifests', () => { ); expect(getRootPackageJson(files2).sideEffects).toEqual(['*.css']); }); + + it('should conditionally inject tslib into dependencies only when hasTslibImport is true', async () => { + const options = createOptions({ + workspaceRoot: process.cwd(), + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + + const filesWithoutTslib = await generatePackageManifests(options, false, false); + expect(getRootPackageJson(filesWithoutTslib).dependencies).toBeUndefined(); + + const filesWithTslib = await generatePackageManifests(options, false, true); + expect(getRootPackageJson(filesWithTslib).dependencies).toEqual({ + tslib: jasmine.any(String), + }); + }); + + it('should preserve existing user tslib declaration when hasTslibImport is true', async () => { + const filesInDeps = await generatePackageManifests( + createOptions({ + workspaceRoot: process.cwd(), + packageJson: { + name: 'my-lib', + version: '1.0.0', + dependencies: { + tslib: '^2.0.0', + }, + }, + }), + false, + true, + ); + expect(getRootPackageJson(filesInDeps).dependencies).toEqual({ + tslib: '^2.0.0', + }); + }); }); diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts index 6020973a90e1..8258c8874e42 100644 --- a/packages/angular/build/src/builders/library/pipeline/utils.ts +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -6,6 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import fs from 'node:fs/promises'; +import type { PackageJsonData } from '../types'; + const IS_DTS_FILE_REGEXP = /\.d\.[cm]?ts$/i; const IS_DTS_MAP_FILE_REGEXP = /\.d\.[cm]?ts\.map$/i; @@ -105,3 +108,15 @@ export function isDeclarationFile(path: string): boolean { export function isDeclarationSourceMapFile(path: string): boolean { return IS_DTS_MAP_FILE_REGEXP.test(path); } + +/** + * Loads and parses a package.json file from disk. + * + * @param packageJsonPath The path to the package.json file. + * @returns The parsed package.json data. + */ +export async function loadPackageJson(packageJsonPath: string): Promise { + const content = await fs.readFile(packageJsonPath, 'utf-8'); + + return JSON.parse(content) as PackageJsonData; +} diff --git a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts index 24ada95cda1f..74150e57acb7 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts @@ -37,6 +37,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => harness.expectFile('dist/lib/package.json').toExist(); const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkgJson.dependencies).toBeUndefined(); expect(pkgJson).toEqual( jasmine.objectContaining({ name: 'lib', @@ -72,5 +73,71 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }), ); }); + + it('should automatically add tslib to dependencies when output chunks import tslib', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + function CustomClassDecorator(): ClassDecorator { + return () => {}; + } + + @CustomClassDecorator() + export class DecoratedService {} + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.error).toBeUndefined(); + expect(result?.success).toBeTrue(); + + const fesmContent = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesmContent).toContain('tslib'); + + const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkgJson.dependencies).toEqual({ + tslib: jasmine.any(String), + }); + }); + + it('should preserve existing user tslib version in package.json when output chunks import tslib', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + function CustomClassDecorator(): ClassDecorator { + return () => {}; + } + + @CustomClassDecorator() + export class DecoratedService {} + `, + ); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + tslib: '^2.0.0', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.error).toBeUndefined(); + expect(result?.success).toBeTrue(); + + const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkgJson.dependencies).toEqual({ + tslib: '^2.0.0', + }); + }); }); }); diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index 03213842a30c..2533da417909 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -515,5 +515,53 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should update package.json in watch mode when tslib import is added or removed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toBeUndefined(); + + // Add a custom decorator that causes tslib to be imported + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + function CustomClassDecorator(): ClassDecorator { + return () => {}; + } + + @CustomClassDecorator() + export class DecoratedService {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toEqual({ + tslib: jasmine.any(String), + }); + + // Remove the custom decorator so tslib is no longer imported + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + export class PlainService {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toBeUndefined(); + }, + ]); + }); }); }); diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts index 7eb7c2feea38..dfe91c36f3c5 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -98,6 +98,9 @@ export interface BundleResult { /** Exact set of virtual DTS module IDs bundled into this entry point. */ dtsModuleIds: ReadonlySet; + + /** Whether this entry point bundle imports 'tslib'. */ + hasTslibImport?: boolean; } /** @@ -121,13 +124,33 @@ export interface SingleProgramCache { * State preserved across incremental builds in watch mode. */ export interface SingleBuildState { + /** Cached unified TypeScript and Angular compilation state. */ singleProgramCache?: SingleProgramCache; + + /** Map of entry point names to their previous bundle results. */ previousBundleResults: Map; + + /** Set of changed ESM file paths pending bundling. */ pendingChangedEsmFiles: Set; + + /** Set of changed TypeScript declaration file paths pending bundling. */ pendingChangedDtsFiles: Set; + + /** Whether the previous build iteration encountered a compilation or diagnostic error. */ hasCompilationError?: boolean; + + /** Whether package.json and secondary entry point manifests have been emitted. */ hasEmittedManifests?: boolean; + + /** Whether the last emitted root package.json included a tslib dependency. */ + hasEmittedTslib?: boolean; + + /** Whether static assets have been emitted in a prior iteration. */ hasEmittedAssets?: boolean; + + /** Whether entry points in package.json changed since the last compilation. */ hasEntryPointsChanges?: boolean; + + /** Cache of output directory paths known to exist on disk. */ directoryExists: Set; }