Skip to content

Commit

Permalink
fix(material/core): prevent updates to v17 if project uses legacy com…
Browse files Browse the repository at this point in the history
…ponents (angular#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.
  • Loading branch information
crisbeto committed Nov 1, 2023
1 parent ab8d7e5 commit f991425
Show file tree
Hide file tree
Showing 6 changed files with 200 additions and 12 deletions.
12 changes: 6 additions & 6 deletions guides/v15-mdc-migration.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/material/schematics/migration.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
1 change: 1 addition & 0 deletions src/material/schematics/ng-update/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions src/material/schematics/ng-update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,24 @@ 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';

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,
),
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string, any> | null = null;

try {
packageJson = JSON.parse(tree.readText('package.json')) as Record<string, any>;
} 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>): 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`
);
}
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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<string, any>;
expect(content['dependencies']['@angular/material']).toBe('^16.2.0');
});
});

0 comments on commit f991425

Please sign in to comment.