Skip to content

Commit f991425

Browse files
authored
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.
1 parent ab8d7e5 commit f991425

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)