Skip to content

Commit fcaa95e

Browse files
committed
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. (cherry picked from commit f991425)
1 parent b361de1 commit fcaa95e

File tree

6 files changed

+200
-12
lines changed

6 files changed

+200
-12
lines changed

guides/v15-mdc-migration.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Migrating to MDC-based Angular Material Components
22

3-
In Angular Material v15, many of the components have been refactored to be based on the official
3+
In Angular Material v15 and later, many of the components have been refactored to be based on the official
44
[Material Design Components for Web (MDC)](https://github.com/material-components/material-components-web).
55
The components from the following imports have been refactored:
66

@@ -81,22 +81,22 @@ practices before migrating.
8181
component. Using component harnesses makes your tests easier to understand and more robust to
8282
changes in Angular Material
8383

84-
### 1. Update to Angular Material v15
84+
### 1. Update to Angular Material v16
8585

8686
Angular Material includes a schematic to help migrate applications to use the new MDC-based
87-
components. To get started, upgrade your application to Angular Material 15.
87+
components. To get started, upgrade your application to Angular Material 16.
8888

8989
```shell
90-
ng update @angular/material@15
90+
ng update @angular/material@16
9191
```
9292

9393
As part of this update, a schematic will run to automatically move your application to use the
9494
"legacy" imports containing the old component implementations. This provides a quick path to getting
95-
your application running on v15 with minimal manual changes.
95+
your application running on v16 with minimal manual changes.
9696

9797
### 2. Run the migration tool
9898

99-
After upgrading to v15, you can run the migration tool to switch from the legacy component
99+
After upgrading to v16, you can run the migration tool to switch from the legacy component
100100
implementations to the new MDC-based ones.
101101

102102
```shell

src/material/schematics/migration.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"schematics": {
44
"migration-v17": {
55
"version": "17.0.0-0",
6-
"description": "Updates the Angular Material to v17",
6+
"description": "Updates Angular Material to v17",
77
"factory": "./ng-update/index_bundled#updateToV17"
88
}
99
}

src/material/schematics/ng-update/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ ts_library(
6969
"//src/cdk/schematics",
7070
"//src/cdk/schematics/testing",
7171
"//src/material/schematics:paths",
72+
"@npm//@angular-devkit/core",
7273
"@npm//@angular-devkit/schematics",
7374
"@npm//@bazel/runfiles",
7475
"@npm//@types/jasmine",

src/material/schematics/ng-update/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,24 @@ import {
1313
TargetVersion,
1414
} from '@angular/cdk/schematics';
1515

16+
import {legacyImportsError} from './migrations/legacy-imports-error';
1617
import {materialUpgradeData} from './upgrade-data';
1718
import {ThemeBaseMigration} from './migrations/theme-base-v17';
1819

1920
const materialMigrations: NullableDevkitMigration[] = [ThemeBaseMigration];
2021

2122
/** Entry point for the migration schematics with target of Angular Material v17 */
2223
export function updateToV17(): Rule {
23-
return createMigrationSchematicRule(
24-
TargetVersion.V17,
25-
materialMigrations,
26-
materialUpgradeData,
27-
onMigrationComplete,
24+
// We pass the v17 migration rule as a callback, instead of using `chain()`, because the
25+
// legacy imports error only logs an error message, it doesn't actually interrupt the migration
26+
// process and we don't want to execute migrations if there are leftover legacy imports.
27+
return legacyImportsError(
28+
createMigrationSchematicRule(
29+
TargetVersion.V17,
30+
materialMigrations,
31+
materialUpgradeData,
32+
onMigrationComplete,
33+
),
2834
);
2935
}
3036

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics';
10+
import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks';
11+
import * as ts from 'typescript';
12+
13+
/** String with which legacy imports start. */
14+
const LEGACY_IMPORTS_START = '@angular/material/legacy-';
15+
16+
/** Maximum files to print in the error message. */
17+
const MAX_FILES_TO_PRINT = 50;
18+
19+
/**
20+
* "Migration" that logs an error and prevents further migrations
21+
* from running if the project is using legacy components.
22+
* @param onSuccess Rule to run if there are no legacy imports.
23+
*/
24+
export function legacyImportsError(onSuccess: Rule): Rule {
25+
return async (tree: Tree, context: SchematicContext) => {
26+
const filesUsingLegacyImports = new Set<string>();
27+
28+
tree.visit(path => {
29+
if (!path.endsWith('.ts')) {
30+
return;
31+
}
32+
33+
const content = tree.readText(path);
34+
const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest);
35+
36+
sourceFile.forEachChild(function walk(node) {
37+
const isImportOrExport = ts.isImportDeclaration(node) || ts.isExportDeclaration(node);
38+
39+
if (
40+
isImportOrExport &&
41+
node.moduleSpecifier &&
42+
ts.isStringLiteralLike(node.moduleSpecifier) &&
43+
node.moduleSpecifier.text.startsWith(LEGACY_IMPORTS_START)
44+
) {
45+
filesUsingLegacyImports.add(path);
46+
}
47+
48+
node.forEachChild(walk);
49+
});
50+
});
51+
52+
// If there are no legacy imports left, we can continue with the migrations.
53+
if (filesUsingLegacyImports.size === 0) {
54+
return onSuccess;
55+
}
56+
57+
// At this point the project is already at v17 so we need to downgrade it back
58+
// to v16 and run `npm install` again. Ideally we would also throw an error here
59+
// to interrupt the update process, but that would interrupt `npm install` as well.
60+
if (tree.exists('package.json')) {
61+
let packageJson: Record<string, any> | null = null;
62+
63+
try {
64+
packageJson = JSON.parse(tree.readText('package.json')) as Record<string, any>;
65+
} catch {}
66+
67+
if (packageJson !== null && packageJson['dependencies']) {
68+
packageJson['dependencies']['@angular/material'] = '^16.2.0';
69+
tree.overwrite('package.json', JSON.stringify(packageJson, null, 2));
70+
context.addTask(new NodePackageInstallTask());
71+
}
72+
}
73+
74+
context.logger.fatal(formatErrorMessage(filesUsingLegacyImports));
75+
return;
76+
};
77+
}
78+
79+
function formatErrorMessage(filesUsingLegacyImports: Set<string>): string {
80+
const files = Array.from(filesUsingLegacyImports, path => ' - ' + path);
81+
const filesMessage =
82+
files.length > MAX_FILES_TO_PRINT
83+
? [
84+
...files.slice(0, MAX_FILES_TO_PRINT),
85+
`${files.length - MAX_FILES_TO_PRINT} more...`,
86+
`Search your project for "${LEGACY_IMPORTS_START}" to view all usages.`,
87+
].join('\n')
88+
: files.join('\n');
89+
90+
return (
91+
`Cannot update to Angular Material v17 because the project is using the legacy ` +
92+
`Material components\nthat have been deleted. While Angular Material v16 is compatible with ` +
93+
`Angular v17, it is recommended\nto switch away from the legacy components as soon as possible ` +
94+
`because they no longer receive bug fixes,\naccessibility improvements and new features.\n\n` +
95+
`Read more about migrating away from legacy components: https://material.angular.io/guide/mdc-migration\n\n` +
96+
`Files in the project using legacy Material components:\n${filesMessage}\n`
97+
);
98+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {createTestCaseSetup} from '@angular/cdk/schematics/testing';
2+
import {UnitTestTree} from '@angular-devkit/schematics/testing';
3+
import {logging} from '@angular-devkit/core';
4+
import {MIGRATION_PATH} from '../../paths';
5+
6+
describe('legacy imports error', () => {
7+
const PATH = 'projects/material-testing/';
8+
let runFixers: () => Promise<unknown>;
9+
let tree: UnitTestTree;
10+
let writeFile: (path: string, content: string) => void;
11+
let fatalLogs: string[];
12+
13+
beforeEach(async () => {
14+
const setup = await createTestCaseSetup('migration-v17', MIGRATION_PATH, []);
15+
runFixers = setup.runFixers;
16+
writeFile = setup.writeFile;
17+
tree = setup.appTree;
18+
fatalLogs = [];
19+
setup.runner.logger.subscribe((entry: logging.LogEntry) => {
20+
if (entry.level === 'fatal') {
21+
fatalLogs.push(entry.message);
22+
}
23+
});
24+
});
25+
26+
afterEach(() => {
27+
runFixers = tree = writeFile = fatalLogs = null!;
28+
});
29+
30+
it('should log a fatal message if the app imports a legacy import', async () => {
31+
writeFile(
32+
`${PATH}/src/app/app.module.ts`,
33+
`
34+
import {NgModule} from '@angular/core';
35+
import {MatLegacyButtonModule} from '@angular/material/legacy-button';
36+
37+
@NgModule({
38+
imports: [MatLegacyButtonModule],
39+
})
40+
export class AppModule {}
41+
`,
42+
);
43+
44+
await runFixers();
45+
46+
expect(fatalLogs.length).toBe(1);
47+
expect(fatalLogs[0]).toContain(
48+
'Cannot update to Angular Material v17, ' +
49+
'because the project is using the legacy Material components',
50+
);
51+
});
52+
53+
it('should downgrade the app to v16 if it contains legacy imports', async () => {
54+
writeFile(
55+
`${PATH}/package.json`,
56+
`{
57+
"name": "test",
58+
"version": "0.0.0",
59+
"dependencies": {
60+
"@angular/material": "^17.0.0"
61+
}
62+
}`,
63+
);
64+
65+
writeFile(
66+
`${PATH}/src/app/app.module.ts`,
67+
`
68+
import {NgModule} from '@angular/core';
69+
import {MatLegacyButtonModule} from '@angular/material/legacy-button';
70+
71+
@NgModule({
72+
imports: [MatLegacyButtonModule],
73+
})
74+
export class AppModule {}
75+
`,
76+
);
77+
78+
await runFixers();
79+
80+
const content = JSON.parse(tree.readText('/package.json')) as Record<string, any>;
81+
expect(content['dependencies']['@angular/material']).toBe('^16.2.0');
82+
});
83+
});

0 commit comments

Comments
 (0)