diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 4afb4facc7a3..a70c930290dd 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -10,6 +10,11 @@ "factory": "./replace-provide-server-routing/migration", "description": "Migrate 'provideServerRendering' to use 'withRoutes' and remove 'provideServerRouting' from '@angular/ssr'." }, + "update-module-resolution": { + "version": "20.0.0", + "factory": "./update-module-resolution/migration", + "description": "Update 'moduleResolution' to 'bundler' in TypeScript configurations. You can read more about this, here: https://www.typescriptlang.org/tsconfig/#moduleResolution" + }, "use-application-builder": { "version": "20.0.0", "factory": "./use-application-builder/migration", diff --git a/packages/schematics/angular/migrations/update-module-resolution/migration.ts b/packages/schematics/angular/migrations/update-module-resolution/migration.ts new file mode 100644 index 000000000000..ca0419a4eeab --- /dev/null +++ b/packages/schematics/angular/migrations/update-module-resolution/migration.ts @@ -0,0 +1,60 @@ +/** + * @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 { JsonObject } from '@angular-devkit/core'; +import { Rule, Tree } from '@angular-devkit/schematics'; +import { JSONFile } from '../../utility/json-file'; +import { allTargetOptions, allWorkspaceTargets, getWorkspace } from '../../utility/workspace'; + +export default function (): Rule { + return async (host) => { + const uniqueTsConfigs = new Set(); + + if (host.exists('tsconfig.json')) { + // Workspace level tsconfig + uniqueTsConfigs.add('tsconfig.json'); + } + + const workspace = await getWorkspace(host); + for (const [, target] of allWorkspaceTargets(workspace)) { + for (const [, opt] of allTargetOptions(target)) { + if (typeof opt?.tsConfig === 'string') { + uniqueTsConfigs.add(opt.tsConfig); + } + } + } + + for (const tsConfig of uniqueTsConfigs) { + if (host.exists(tsConfig)) { + updateModuleResolution(host, tsConfig); + } + } + }; +} + +function updateModuleResolution(host: Tree, tsConfigPath: string): void { + const json = new JSONFile(host, tsConfigPath); + const jsonPath = ['compilerOptions']; + const compilerOptions = json.get(jsonPath); + + if (compilerOptions && typeof compilerOptions === 'object') { + const { moduleResolution, module } = compilerOptions as JsonObject; + if (typeof moduleResolution !== 'string' || moduleResolution.toLowerCase() === 'bundler') { + return; + } + + if (typeof module === 'string' && module.toLowerCase() === 'preserve') { + return; + } + + json.modify(jsonPath, { + ...compilerOptions, + 'moduleResolution': 'bundler', + }); + } +} diff --git a/packages/schematics/angular/migrations/update-module-resolution/migration_spec.ts b/packages/schematics/angular/migrations/update-module-resolution/migration_spec.ts new file mode 100644 index 000000000000..53448e80b66a --- /dev/null +++ b/packages/schematics/angular/migrations/update-module-resolution/migration_spec.ts @@ -0,0 +1,118 @@ +/** + * @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 moduleResolution', () => { + const schematicName = 'update-module-resolution'; + 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 getCompilerOptionsValue(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'.`); + } + + 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', + }, + }, + }, + }, + }, + }; + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + const compilerOptions = { module: 'es2020', moduleResolution: 'node' }; + const configWithExtends = { extends: './tsconfig.json', compilerOptions }; + + // Workspace + createJsonFile(tree, 'angular.json', angularConfig); + 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 update moduleResolution to 'bundler' in workspace 'tsconfig.json'`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptionsValue(newTree, 'tsconfig.json'); + expect(compilerOptions).toEqual( + jasmine.objectContaining({ + moduleResolution: 'bundler', + }), + ); + }); + + it(`should update moduleResolution to 'bundler' in builder tsconfig`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptionsValue(newTree, 'src/tsconfig.spec.json'); + expect(compilerOptions).toEqual( + jasmine.objectContaining({ + moduleResolution: 'bundler', + }), + ); + }); + + it('should not update moduleResolution when module is preserve', async () => { + createJsonFile(tree, 'tsconfig.json', { + compilerOptions: { module: 'preserve', moduleResolution: 'node' }, + }); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const compilerOptions = getCompilerOptionsValue(newTree, 'tsconfig.json'); + expect(compilerOptions).toEqual({ module: 'preserve', moduleResolution: 'node' }); + }); +});