diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 72d2cdef4030..21f156a5cb31 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -13,6 +13,11 @@ "version": "21.0.0", "factory": "./karma/migration", "description": "Remove any karma configuration files that only contain the default content. The default configuration is automatically available without a specific project file." + }, + "update-typescript-lib": { + "version": "21.0.0", + "factory": "./update-typescript-lib/migration", + "description": "Updates the 'lib' property in tsconfig files to use 'es2022' or a more modern version." } } } diff --git a/packages/schematics/angular/migrations/update-typescript-lib/migration.ts b/packages/schematics/angular/migrations/update-typescript-lib/migration.ts new file mode 100644 index 000000000000..9022bcedf578 --- /dev/null +++ b/packages/schematics/angular/migrations/update-typescript-lib/migration.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Rule, Tree } from '@angular-devkit/schematics'; +import { JSONFile } from '../../utility/json-file'; +import { getWorkspace } from '../../utility/workspace'; + +export default function (): Rule { + return async (host, context) => { + // Workspace level tsconfig + if (host.exists('tsconfig.json')) { + updateLib(host, 'tsconfig.json'); + } + + const workspace = await getWorkspace(host); + + // Find all tsconfig which are references used by builders + for (const [, project] of workspace.projects) { + for (const [targetName, target] of project.targets) { + if (!target.options) { + continue; + } + + // Update all other known CLI builders that use a tsconfig + const tsConfigs = [target.options, ...Object.values(target.configurations || {})] + .filter((opt) => typeof opt?.tsConfig === 'string') + .map((opt) => (opt as { tsConfig: string }).tsConfig); + + const uniqueTsConfigs = new Set(tsConfigs); + for (const tsConfig of uniqueTsConfigs) { + if (host.exists(tsConfig)) { + updateLib(host, tsConfig); + } else { + context.logger.warn( + `'${tsConfig}' referenced in the '${targetName}' target does not exist.`, + ); + } + } + } + } + }; +} + +function updateLib(host: Tree, tsConfigPath: string): void { + const json = new JSONFile(host, tsConfigPath); + const jsonPath = ['compilerOptions', 'lib']; + const lib = json.get(jsonPath) as string[] | undefined; + + if (!lib || !Array.isArray(lib)) { + return; + } + + const esLibs = lib.filter((l) => typeof l === 'string' && l.toLowerCase().startsWith('es')); + const hasDom = lib.some((l) => typeof l === 'string' && l.toLowerCase() === 'dom'); + + if (esLibs.length === 0) { + return; + } + + const esLibToVersion = new Map(); + for (const l of esLibs) { + const version = l.toLowerCase().match(/^es(next|(\d+))$/)?.[1]; + if (version) { + esLibToVersion.set(l, version === 'next' ? Infinity : Number(version)); + } + } + + if (esLibToVersion.size === 0) { + return; + } + + const latestEsLib = [...esLibToVersion.entries()].sort(([, v1], [, v2]) => v2 - v1)[0]; + const latestVersion = latestEsLib[1]; + + if (hasDom) { + if (latestVersion <= 2022) { + json.remove(jsonPath); + } + + return; + } + + // No 'dom' with 'es' libs, so update 'es' lib. + if (latestVersion < 2022) { + const newLibs = lib.filter((l) => !esLibToVersion.has(l)); + newLibs.push('es2022'); + json.modify(jsonPath, newLibs); + } +} diff --git a/packages/schematics/angular/migrations/update-typescript-lib/migration_spec.ts b/packages/schematics/angular/migrations/update-typescript-lib/migration_spec.ts new file mode 100644 index 000000000000..6cbfe6ebb00a --- /dev/null +++ b/packages/schematics/angular/migrations/update-typescript-lib/migration_spec.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject } from '@angular-devkit/core'; +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Builders, ProjectType, WorkspaceSchema } from '../../utility/workspace-models'; + +describe('Migration to update TypeScript lib', () => { + const schematicName = 'update-typescript-lib'; + + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + function createJsonFile(tree: UnitTestTree, filePath: string, content: {}): void { + const stringifiedContent = JSON.stringify(content, undefined, 2); + if (tree.exists(filePath)) { + tree.overwrite(filePath, stringifiedContent); + } else { + tree.create(filePath, stringifiedContent); + } + } + + function getCompilerOptions(tree: UnitTestTree, filePath: string): Record { + const json = tree.readJson(filePath); + if (isJsonObject(json) && isJsonObject(json.compilerOptions)) { + return json.compilerOptions; + } + + throw new Error(`Cannot retrieve 'compilerOptions'.`); + } + + function createWorkSpaceConfig(tree: UnitTestTree) { + const angularConfig: WorkspaceSchema = { + version: 1, + projects: { + app: { + root: '', + sourceRoot: 'src', + projectType: ProjectType.Application, + prefix: 'app', + architect: { + build: { + builder: Builders.Browser, + options: { + tsConfig: 'src/tsconfig.app.json', + main: '', + polyfills: '', + }, + configurations: { + production: { + tsConfig: 'src/tsconfig.app.prod.json', + }, + }, + }, + test: { + builder: Builders.Karma, + options: { + karmaConfig: '', + tsConfig: 'src/tsconfig.spec.json', + }, + }, + }, + }, + }, + }; + + createJsonFile(tree, 'angular.json', angularConfig); + } + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + createWorkSpaceConfig(tree); + + // Create tsconfigs + const compilerOptions = { lib: ['es2020', 'dom'] }; + const configWithExtends = { extends: './tsconfig.json', compilerOptions }; + + // Workspace + createJsonFile(tree, 'tsconfig.json', { compilerOptions }); + + // Application + createJsonFile(tree, 'src/tsconfig.app.json', configWithExtends); + createJsonFile(tree, 'src/tsconfig.app.prod.json', configWithExtends); + createJsonFile(tree, 'src/tsconfig.spec.json', { compilerOptions }); + }); + + it(`should remove 'lib' when 'dom' is present and ES version is less than 2022`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toBeUndefined(); + }); + + it(`should remove 'lib' when 'dom' is present and ES version is 2022`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { lib: ['es2022', 'dom'] } }); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toBeUndefined(); + }); + + it(`should not remove 'lib' when 'dom' is present and ES version is 'esnext'`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { lib: ['esnext', 'dom'] } }); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toEqual(['esnext', 'dom']); + }); + + it(`should update 'lib' to 'es2022' when 'dom' is not present and ES version is less than 2022`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { lib: ['es2020'] } }); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toEqual(['es2022']); + }); + + it(`should not update 'lib' when 'dom' is not present and ES version is 2022`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { lib: ['es2022'] } }); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toEqual(['es2022']); + }); + + it(`should not update 'lib' when 'dom' is not present and ES version is 'esnext'`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { lib: ['esnext'] } }); + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptions(newTree, 'tsconfig.json'); + expect(compilerOptions.lib).toEqual(['esnext']); + }); + + it('should not error when a tsconfig is not found', async () => { + tree.delete('src/tsconfig.spec.json'); + await schematicRunner.runSchematic(schematicName, {}, tree); + }); + + it('should not error when compilerOptions is not defined', async () => { + createJsonFile(tree, 'tsconfig.json', {}); + await schematicRunner.runSchematic(schematicName, {}, tree); + }); + + it(`should not error when 'lib' is not defined`, async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: {} }); + await schematicRunner.runSchematic(schematicName, {}, tree); + }); + + it(`should remove 'lib' from all tsconfigs`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + expect(getCompilerOptions(newTree, 'tsconfig.json').lib).toBeUndefined(); + expect(getCompilerOptions(newTree, 'src/tsconfig.app.json').lib).toBeUndefined(); + expect(getCompilerOptions(newTree, 'src/tsconfig.app.prod.json').lib).toBeUndefined(); + expect(getCompilerOptions(newTree, 'src/tsconfig.spec.json').lib).toBeUndefined(); + }); +});