Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(material/core): prevent updates to v17 if project uses legacy components #28024

Merged
merged 1 commit into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't go through the DevkitMigration for this schematic, because it isn't a standard migration schematic and the operations in it are pretty basic so it wasn't worth it to update the common infrastructure.

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));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we have a utility for adding dependencies to the package.json (in at least two places!), however my understanding is that we can't put it into a common spot, because the other places are ng add schematics and they might not be able to import other packages yet (I may be wrong). This operation is pretty basic so we should be fine.

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');
});
});
Loading