-
Notifications
You must be signed in to change notification settings - Fork 6.7k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that we have a utility for adding dependencies to the |
||
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'); | ||
}); | ||
}); |
There was a problem hiding this comment.
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.