Skip to content

Commit 3c140fd

Browse files
authored
fix(material/schematics): incorrectly migrating some cases (#22983)
Fixes the following cases that the migration wasn't handling correctly: * Variables whose names partially match the name of the variables being migrated. E.g. `$mat-blue-override`. * Functions whose names partially match the names of function being migrated. E.g. `my-mat-palette`. * Files where the top import comes from Material, but which have other imports further down in the file.
1 parent 24d33a8 commit 3c140fd

File tree

3 files changed

+79
-33
lines changed

3 files changed

+79
-33
lines changed

src/material/schematics/ng-update/migrations/theming-api-v12/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export const removedMaterialVariables: Record<string, string> = {
138138
'mat-expansion-panel-header-expanded-minimum-height': '48px',
139139
'mat-expansion-panel-header-expanded-maximum-height': '64px',
140140
'mat-expansion-panel-header-transition': '225ms cubic-bezier(0.4, 0, 0.2, 1)',
141+
'mat-menu-side-padding': '16px',
142+
'menu-menu-item-height': '48px',
143+
'menu-menu-icon-margin': '16px',
141144
'mat-paginator-height': '56px',
142145
'mat-paginator-minimum-height': '40px',
143146
'mat-paginator-maximum-height': '56px',

src/material/schematics/ng-update/migrations/theming-api-v12/migration.ts

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export function migrateFileContent(content: string,
5656

5757
// Try to migrate the symbols even if there are no imports. This is used
5858
// to cover the case where the Components symbols were used transitively.
59+
content = migrateCdkSymbols(content, newCdkImportPath, cdkResults);
5960
content = migrateMaterialSymbols(
6061
content, newMaterialImportPath, materialResults, extraMaterialSymbols);
61-
content = migrateCdkSymbols(content, newCdkImportPath, cdkResults);
6262
content = replaceRemovedVariables(content, removedMaterialVariables);
6363

6464
// We can assume that the migration has taken care of any Components symbols that were
@@ -142,7 +142,7 @@ function migrateMaterialSymbols(content: string, importPath: string,
142142

143143
if (content !== initialContent) {
144144
// Add an import to the new API only if any of the APIs were being used.
145-
content = insertUseStatement(content, importPath, detectedImports.imports, namespace);
145+
content = insertUseStatement(content, importPath, namespace);
146146
}
147147

148148
return content;
@@ -165,7 +165,7 @@ function migrateCdkSymbols(content: string, importPath: string,
165165
// Previously the CDK symbols were exposed through `material/theming`, but now we have a
166166
// dedicated entrypoint for the CDK. Only add an import for it if any of the symbols are used.
167167
if (content !== initialContent) {
168-
content = insertUseStatement(content, importPath, detectedImports.imports, namespace);
168+
content = insertUseStatement(content, importPath, namespace);
169169
}
170170

171171
return content;
@@ -186,10 +186,8 @@ function renameSymbols(content: string,
186186
getKeyPattern: (namespace: string|null, key: string) => RegExp,
187187
formatValue: (key: string) => string): string {
188188
// The null at the end is so that we make one last pass to cover non-namespaced symbols.
189-
[...namespaces.slice().sort(sortLengthDescending), null].forEach(namespace => {
190-
// Migrate the longest keys first so that our regex-based replacements don't accidentally
191-
// capture keys that contain other keys. E.g. `$mat-blue` is contained within `$mat-blue-grey`.
192-
Object.keys(mapping).sort(sortLengthDescending).forEach(key => {
189+
[...namespaces.slice(), null].forEach(namespace => {
190+
Object.keys(mapping).forEach(key => {
193191
const pattern = getKeyPattern(namespace, key);
194192

195193
// Sanity check since non-global regexes will only replace the first match.
@@ -205,26 +203,24 @@ function renameSymbols(content: string,
205203
}
206204

207205
/** Inserts an `@use` statement in a string. */
208-
function insertUseStatement(content: string, importPath: string, importsToIgnore: string[],
209-
namespace: string): string {
206+
function insertUseStatement(content: string, importPath: string, namespace: string): string {
210207
// If the content already has the `@use` import, we don't need to add anything.
211-
const alreadyImportedPattern = new RegExp(`@use +['"]${importPath}['"]`, 'g');
212-
if (alreadyImportedPattern.test(content)) {
208+
if (new RegExp(`@use +['"]${importPath}['"]`, 'g').test(content)) {
213209
return content;
214210
}
215211

216-
// We want to find the first import that isn't in the list of ignored imports or find nothing,
217-
// because the imports being replaced might be the only ones in the file and they can be further
218-
// down. An easy way to do this is to replace the imports with a random character and run
219-
// `indexOf` on the result. This isn't the most efficient way of doing it, but it's more compact
220-
// and it allows us to easily deal with things like comment nodes.
221-
const contentToSearch = importsToIgnore.reduce((accumulator, current) =>
222-
accumulator.replace(current, '◬'.repeat(current.length)), content);
223-
224-
// Sass has a limitation that all `@use` declarations have to come before `@import` so we have
225-
// to find the first import and insert before it. Technically we can get away with always
226-
// inserting at 0, but the file may start with something like a license header.
227-
const newImportIndex = Math.max(0, contentToSearch.indexOf('@import '));
212+
// Sass will throw an error if an `@use` statement comes after another statement. The safest way
213+
// to ensure that we conform to that requirement is by always inserting our imports at the top
214+
// of the file. Detecting where the user's content starts is tricky, because there are many
215+
// different kinds of syntax we'd have to account for. One approach is to find the first `@import`
216+
// and insert before it, but the problem is that Sass allows `@import` to be placed anywhere.
217+
let newImportIndex = 0;
218+
219+
// One special case is if the file starts with a license header which we want to preserve on top.
220+
if (content.trim().startsWith('/*')) {
221+
const commentEndIndex = content.indexOf('*/', content.indexOf('/*'));
222+
newImportIndex = content.indexOf('\n', commentEndIndex) + 1;
223+
}
228224

229225
return content.slice(0, newImportIndex) + `@use '${importPath}' as ${namespace};\n` +
230226
content.slice(newImportIndex);
@@ -247,7 +243,8 @@ function getMixinValueFormatter(namespace: string): (name: string) => string {
247243

248244
/** Formats a migration key as a Sass function invocation. */
249245
function functionKeyFormatter(namespace: string|null, name: string): RegExp {
250-
return new RegExp(escapeRegExp(`${namespace ? namespace + '.' : ''}${name}(`), 'g');
246+
const functionName = escapeRegExp(`${namespace ? namespace + '.' : ''}${name}(`);
247+
return new RegExp(`(?<![-_a-zA-Z0-9])${functionName}`, 'g');
251248
}
252249

253250
/** Returns a function that can be used to format a Sass function replacement. */
@@ -257,7 +254,8 @@ function getFunctionValueFormatter(namespace: string): (name: string) => string
257254

258255
/** Formats a migration key as a Sass variable. */
259256
function variableKeyFormatter(namespace: string|null, name: string): RegExp {
260-
return new RegExp(escapeRegExp(`${namespace ? namespace + '.' : ''}$${name}`), 'g');
257+
const variableName = escapeRegExp(`${namespace ? namespace + '.' : ''}$${name}`);
258+
return new RegExp(`${variableName}(?![-_a-zA-Z0-9])`, 'g');
261259
}
262260

263261
/** Returns a function that can be used to format a Sass variable replacement. */
@@ -270,11 +268,6 @@ function escapeRegExp(str: string): string {
270268
return str.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
271269
}
272270

273-
/** Used with `Array.prototype.sort` to order strings in descending length. */
274-
function sortLengthDescending(a: string, b: string) {
275-
return b.length - a.length;
276-
}
277-
278271
/** Removes all strings from another string. */
279272
function removeStrings(content: string, toRemove: string[]): string {
280273
return toRemove
@@ -325,7 +318,7 @@ function extractNamespaceFromUseStatement(fullImport: string): string {
325318
* @param variables Mapping between variable names and their values.
326319
*/
327320
function replaceRemovedVariables(content: string, variables: Record<string, string>): string {
328-
Object.keys(variables).sort(sortLengthDescending).forEach(variableName => {
321+
Object.keys(variables).forEach(variableName => {
329322
// Note that the pattern uses a negative lookahead to exclude
330323
// variable assignments, because they can't be migrated.
331324
const regex = new RegExp(`\\$${escapeRegExp(variableName)}(?!\\s+:|:)`, 'g');

src/material/schematics/ng-update/test-cases/v12/misc/theming-api-v12.spec.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,8 @@ describe('v12 theming API migration', () => {
272272
await runMigration();
273273

274274
expect(splitFile(THEME_PATH)).toEqual([
275-
`@use './foo'`,
276275
`@use '~@angular/material' as mat;`,
276+
`@use './foo'`,
277277
`@import './bar'`,
278278
`@include mat.core();`,
279279
]);
@@ -424,8 +424,8 @@ describe('v12 theming API migration', () => {
424424
await runMigration();
425425

426426
expect(splitFile(THEME_PATH)).toEqual([
427-
`@use '~@angular/cdk' as cdk;`,
428427
`@use '~@angular/material' as mat;`,
428+
`@use '~@angular/cdk' as cdk;`,
429429

430430
`@include cdk.overlay();`,
431431

@@ -754,4 +754,54 @@ describe('v12 theming API migration', () => {
754754
`$another: mat.$pink-palette;`,
755755
]);
756756
});
757+
758+
it('should insert @use before other code when only Angular imports are first', async () => {
759+
writeLines(THEME_PATH, [
760+
`@import '~@angular/material/theming';`,
761+
`$something: 123;`,
762+
`@include mat-core();`,
763+
`@import 'some/other/file';`,
764+
]);
765+
766+
await runMigration();
767+
768+
expect(splitFile(THEME_PATH)).toEqual([
769+
`@use '~@angular/material' as mat;`,
770+
`$something: 123;`,
771+
`@include mat.core();`,
772+
`@import 'some/other/file';`,
773+
]);
774+
});
775+
776+
it('should not rename variables appended with extra characters', async () => {
777+
writeLines(THEME_PATH, [
778+
`@import '~@angular/material/theming';`,
779+
`$mat-light-theme-background-override: 123;`,
780+
`@include mat-core();`,
781+
]);
782+
783+
await runMigration();
784+
785+
expect(splitFile(THEME_PATH)).toEqual([
786+
`@use '~@angular/material' as mat;`,
787+
`$mat-light-theme-background-override: 123;`,
788+
`@include mat.core();`,
789+
]);
790+
});
791+
792+
it('should not rename functions prepended with extra characters', async () => {
793+
writeLines(THEME_PATH, [
794+
`@function gmat-palette() {`,
795+
` @return white;`,
796+
`}`,
797+
]);
798+
799+
await runMigration();
800+
801+
expect(splitFile(THEME_PATH)).toEqual([
802+
`@function gmat-palette() {`,
803+
` @return white;`,
804+
`}`,
805+
]);
806+
});
757807
});

0 commit comments

Comments
 (0)