From f9914251bfc4f96f64675e4dc5b8241a355baa08 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 1 Nov 2023 16:24:35 +0100 Subject: [PATCH] fix(material/core): prevent updates to v17 if project uses legacy components (#28024) These changes add a schematic that will log a fatal error and prevent the app from updating to v17 if it's using legacy components. Legacy components have been deleted in v17 so the app won't build if it updates. --- guides/v15-mdc-migration.md | 12 +-- src/material/schematics/migration.json | 2 +- src/material/schematics/ng-update/BUILD.bazel | 1 + src/material/schematics/ng-update/index.ts | 16 ++- .../migrations/legacy-imports-error.ts | 98 +++++++++++++++++++ .../test-cases/legacy-imports-error.spec.ts | 83 ++++++++++++++++ 6 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 src/material/schematics/ng-update/migrations/legacy-imports-error.ts create mode 100644 src/material/schematics/ng-update/test-cases/legacy-imports-error.spec.ts diff --git a/guides/v15-mdc-migration.md b/guides/v15-mdc-migration.md index df3b2f415452..5f4663b1f151 100644 --- a/guides/v15-mdc-migration.md +++ b/guides/v15-mdc-migration.md @@ -1,6 +1,6 @@ # Migrating to MDC-based Angular Material Components -In Angular Material v15, many of the components have been refactored to be based on the official +In Angular Material v15 and later, many of the components have been refactored to be based on the official [Material Design Components for Web (MDC)](https://github.com/material-components/material-components-web). The components from the following imports have been refactored: @@ -81,22 +81,22 @@ practices before migrating. component. Using component harnesses makes your tests easier to understand and more robust to changes in Angular Material -### 1. Update to Angular Material v15 +### 1. Update to Angular Material v16 Angular Material includes a schematic to help migrate applications to use the new MDC-based -components. To get started, upgrade your application to Angular Material 15. +components. To get started, upgrade your application to Angular Material 16. ```shell -ng update @angular/material@15 +ng update @angular/material@16 ``` As part of this update, a schematic will run to automatically move your application to use the "legacy" imports containing the old component implementations. This provides a quick path to getting -your application running on v15 with minimal manual changes. +your application running on v16 with minimal manual changes. ### 2. Run the migration tool -After upgrading to v15, you can run the migration tool to switch from the legacy component +After upgrading to v16, you can run the migration tool to switch from the legacy component implementations to the new MDC-based ones. ```shell diff --git a/src/material/schematics/migration.json b/src/material/schematics/migration.json index 93326441e6c8..81aa9dfe8b23 100644 --- a/src/material/schematics/migration.json +++ b/src/material/schematics/migration.json @@ -3,7 +3,7 @@ "schematics": { "migration-v17": { "version": "17.0.0-0", - "description": "Updates the Angular Material to v17", + "description": "Updates Angular Material to v17", "factory": "./ng-update/index_bundled#updateToV17" } } diff --git a/src/material/schematics/ng-update/BUILD.bazel b/src/material/schematics/ng-update/BUILD.bazel index ed3caabf009a..7f0c2a4ecbd0 100644 --- a/src/material/schematics/ng-update/BUILD.bazel +++ b/src/material/schematics/ng-update/BUILD.bazel @@ -69,6 +69,7 @@ ts_library( "//src/cdk/schematics", "//src/cdk/schematics/testing", "//src/material/schematics:paths", + "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", "@npm//@bazel/runfiles", "@npm//@types/jasmine", diff --git a/src/material/schematics/ng-update/index.ts b/src/material/schematics/ng-update/index.ts index 91d843c39012..f536c5b5473e 100644 --- a/src/material/schematics/ng-update/index.ts +++ b/src/material/schematics/ng-update/index.ts @@ -13,6 +13,7 @@ import { TargetVersion, } from '@angular/cdk/schematics'; +import {legacyImportsError} from './migrations/legacy-imports-error'; import {materialUpgradeData} from './upgrade-data'; import {ThemeBaseMigration} from './migrations/theme-base-v17'; @@ -20,11 +21,16 @@ const materialMigrations: NullableDevkitMigration[] = [ThemeBaseMigration]; /** Entry point for the migration schematics with target of Angular Material v17 */ export function updateToV17(): Rule { - return createMigrationSchematicRule( - TargetVersion.V17, - materialMigrations, - materialUpgradeData, - onMigrationComplete, + // We pass the v17 migration rule as a callback, instead of using `chain()`, because the + // legacy imports error only logs an error message, it doesn't actually interrupt the migration + // process and we don't want to execute migrations if there are leftover legacy imports. + return legacyImportsError( + createMigrationSchematicRule( + TargetVersion.V17, + materialMigrations, + materialUpgradeData, + onMigrationComplete, + ), ); } diff --git a/src/material/schematics/ng-update/migrations/legacy-imports-error.ts b/src/material/schematics/ng-update/migrations/legacy-imports-error.ts new file mode 100644 index 000000000000..719a7fd6fe5d --- /dev/null +++ b/src/material/schematics/ng-update/migrations/legacy-imports-error.ts @@ -0,0 +1,98 @@ +/** + * @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.io/license + */ + +import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics'; +import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks'; +import * as ts from 'typescript'; + +/** String with which legacy imports start. */ +const LEGACY_IMPORTS_START = '@angular/material/legacy-'; + +/** Maximum files to print in the error message. */ +const MAX_FILES_TO_PRINT = 50; + +/** + * "Migration" that logs an error and prevents further migrations + * from running if the project is using legacy components. + * @param onSuccess Rule to run if there are no legacy imports. + */ +export function legacyImportsError(onSuccess: Rule): Rule { + return async (tree: Tree, context: SchematicContext) => { + const filesUsingLegacyImports = new Set(); + + tree.visit(path => { + if (!path.endsWith('.ts')) { + return; + } + + const content = tree.readText(path); + const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest); + + sourceFile.forEachChild(function walk(node) { + const isImportOrExport = ts.isImportDeclaration(node) || ts.isExportDeclaration(node); + + if ( + isImportOrExport && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) && + node.moduleSpecifier.text.startsWith(LEGACY_IMPORTS_START) + ) { + filesUsingLegacyImports.add(path); + } + + node.forEachChild(walk); + }); + }); + + // If there are no legacy imports left, we can continue with the migrations. + if (filesUsingLegacyImports.size === 0) { + return onSuccess; + } + + // At this point the project is already at v17 so we need to downgrade it back + // to v16 and run `npm install` again. Ideally we would also throw an error here + // to interrupt the update process, but that would interrupt `npm install` as well. + if (tree.exists('package.json')) { + let packageJson: Record | null = null; + + try { + packageJson = JSON.parse(tree.readText('package.json')) as Record; + } catch {} + + if (packageJson !== null && packageJson['dependencies']) { + packageJson['dependencies']['@angular/material'] = '^16.2.0'; + tree.overwrite('package.json', JSON.stringify(packageJson, null, 2)); + context.addTask(new NodePackageInstallTask()); + } + } + + context.logger.fatal(formatErrorMessage(filesUsingLegacyImports)); + return; + }; +} + +function formatErrorMessage(filesUsingLegacyImports: Set): string { + const files = Array.from(filesUsingLegacyImports, path => ' - ' + path); + const filesMessage = + files.length > MAX_FILES_TO_PRINT + ? [ + ...files.slice(0, MAX_FILES_TO_PRINT), + `${files.length - MAX_FILES_TO_PRINT} more...`, + `Search your project for "${LEGACY_IMPORTS_START}" to view all usages.`, + ].join('\n') + : files.join('\n'); + + return ( + `Cannot update to Angular Material v17 because the project is using the legacy ` + + `Material components\nthat have been deleted. While Angular Material v16 is compatible with ` + + `Angular v17, it is recommended\nto switch away from the legacy components as soon as possible ` + + `because they no longer receive bug fixes,\naccessibility improvements and new features.\n\n` + + `Read more about migrating away from legacy components: https://material.angular.io/guide/mdc-migration\n\n` + + `Files in the project using legacy Material components:\n${filesMessage}\n` + ); +} diff --git a/src/material/schematics/ng-update/test-cases/legacy-imports-error.spec.ts b/src/material/schematics/ng-update/test-cases/legacy-imports-error.spec.ts new file mode 100644 index 000000000000..fdd9c27b0f23 --- /dev/null +++ b/src/material/schematics/ng-update/test-cases/legacy-imports-error.spec.ts @@ -0,0 +1,83 @@ +import {createTestCaseSetup} from '@angular/cdk/schematics/testing'; +import {UnitTestTree} from '@angular-devkit/schematics/testing'; +import {logging} from '@angular-devkit/core'; +import {MIGRATION_PATH} from '../../paths'; + +describe('legacy imports error', () => { + const PATH = 'projects/material-testing/'; + let runFixers: () => Promise; + let tree: UnitTestTree; + let writeFile: (path: string, content: string) => void; + let fatalLogs: string[]; + + beforeEach(async () => { + const setup = await createTestCaseSetup('migration-v17', MIGRATION_PATH, []); + runFixers = setup.runFixers; + writeFile = setup.writeFile; + tree = setup.appTree; + fatalLogs = []; + setup.runner.logger.subscribe((entry: logging.LogEntry) => { + if (entry.level === 'fatal') { + fatalLogs.push(entry.message); + } + }); + }); + + afterEach(() => { + runFixers = tree = writeFile = fatalLogs = null!; + }); + + it('should log a fatal message if the app imports a legacy import', async () => { + writeFile( + `${PATH}/src/app/app.module.ts`, + ` + import {NgModule} from '@angular/core'; + import {MatLegacyButtonModule} from '@angular/material/legacy-button'; + + @NgModule({ + imports: [MatLegacyButtonModule], + }) + export class AppModule {} + `, + ); + + await runFixers(); + + expect(fatalLogs.length).toBe(1); + expect(fatalLogs[0]).toContain( + 'Cannot update to Angular Material v17, ' + + 'because the project is using the legacy Material components', + ); + }); + + it('should downgrade the app to v16 if it contains legacy imports', async () => { + writeFile( + `${PATH}/package.json`, + `{ + "name": "test", + "version": "0.0.0", + "dependencies": { + "@angular/material": "^17.0.0" + } + }`, + ); + + writeFile( + `${PATH}/src/app/app.module.ts`, + ` + import {NgModule} from '@angular/core'; + import {MatLegacyButtonModule} from '@angular/material/legacy-button'; + + @NgModule({ + imports: [MatLegacyButtonModule], + }) + export class AppModule {} + `, + ); + + await runFixers(); + + const content = JSON.parse(tree.readText('/package.json')) as Record; + expect(content['dependencies']['@angular/material']).toBe('^16.2.0'); + }); +});