Skip to content

Commit ea3c802

Browse files
mmalerbaalxhub
authored andcommitted
feat(core): Add a schematic to migrate afterRender phase flag (angular#55648)
Adds an `ng update` migration to move users from using the phase flag with `afterRender` / `afterNextRender` to passing a spec object instead. PR Close angular#55648
1 parent 38effcc commit ea3c802

File tree

7 files changed

+358
-0
lines changed

7 files changed

+358
-0
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pkg_npm(
1818
validate = False,
1919
visibility = ["//packages/core:__pkg__"],
2020
deps = [
21+
"//packages/core/schematics/migrations/after-render-phase:bundle",
2122
"//packages/core/schematics/migrations/http-providers:bundle",
2223
"//packages/core/schematics/migrations/invalid-two-way-bindings:bundle",
2324
"//packages/core/schematics/ng-generate/control-flow-migration:bundle",

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
"version": "18.0.0",
1010
"description": "Replace deprecated HTTP related modules with provider functions",
1111
"factory": "./migrations/http-providers/bundle"
12+
},
13+
"migration-after-render-phase": {
14+
"version": "18.1.0",
15+
"description": "Updates calls to afterRender with an explicit phase to the new API",
16+
"factory": "./migrations/after-render-phase/bundle"
1217
}
1318
}
1419
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
load("//tools:defaults.bzl", "esbuild", "ts_library")
2+
3+
package(
4+
default_visibility = [
5+
"//packages/core/schematics:__pkg__",
6+
"//packages/core/schematics/migrations/google3:__pkg__",
7+
"//packages/core/schematics/test:__pkg__",
8+
],
9+
)
10+
11+
ts_library(
12+
name = "after-render-phase",
13+
srcs = glob(["**/*.ts"]),
14+
tsconfig = "//packages/core/schematics:tsconfig.json",
15+
deps = [
16+
"//packages/core/schematics/utils",
17+
"@npm//@angular-devkit/schematics",
18+
"@npm//@types/node",
19+
"@npm//typescript",
20+
],
21+
)
22+
23+
esbuild(
24+
name = "bundle",
25+
entry_point = ":index.ts",
26+
external = [
27+
"@angular-devkit/*",
28+
"typescript",
29+
],
30+
format = "cjs",
31+
platform = "node",
32+
deps = [":after-render-phase"],
33+
)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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, SchematicsException, Tree, UpdateRecorder} from '@angular-devkit/schematics';
10+
import {relative} from 'path';
11+
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
12+
import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host';
13+
import {migrateFile} from './migration';
14+
15+
export default function (): Rule {
16+
return async (tree: Tree) => {
17+
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
18+
const basePath = process.cwd();
19+
const allPaths = [...buildPaths, ...testPaths];
20+
21+
if (!allPaths.length) {
22+
throw new SchematicsException(
23+
'Could not find any tsconfig file. Cannot run the afterRender phase migration.',
24+
);
25+
}
26+
27+
for (const tsconfigPath of allPaths) {
28+
runMigration(tree, tsconfigPath, basePath);
29+
}
30+
};
31+
}
32+
33+
function runMigration(tree: Tree, tsconfigPath: string, basePath: string) {
34+
const program = createMigrationProgram(tree, tsconfigPath, basePath);
35+
const sourceFiles = program
36+
.getSourceFiles()
37+
.filter((sourceFile) => canMigrateFile(basePath, sourceFile, program));
38+
39+
for (const sourceFile of sourceFiles) {
40+
let update: UpdateRecorder | null = null;
41+
42+
const rewriter = (startPos: number, width: number, text: string | null) => {
43+
if (update === null) {
44+
// Lazily initialize update, because most files will not require migration.
45+
update = tree.beginUpdate(relative(basePath, sourceFile.fileName));
46+
}
47+
update.remove(startPos, width);
48+
if (text !== null) {
49+
update.insertLeft(startPos, text);
50+
}
51+
};
52+
migrateFile(sourceFile, program.getTypeChecker(), rewriter);
53+
54+
if (update !== null) {
55+
tree.commitUpdate(update);
56+
}
57+
}
58+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 ts from 'typescript';
10+
import {ChangeTracker} from '../../utils/change_tracker';
11+
import {getImportOfIdentifier, getImportSpecifier} from '../../utils/typescript/imports';
12+
13+
const CORE = '@angular/core';
14+
const AFTER_RENDER_PHASE_ENUM = 'AfterRenderPhase';
15+
const AFTER_RENDER_FNS = new Set(['afterRender', 'afterNextRender']);
16+
17+
type RewriteFn = (startPos: number, width: number, text: string) => void;
18+
19+
export function migrateFile(
20+
sourceFile: ts.SourceFile,
21+
typeChecker: ts.TypeChecker,
22+
rewriteFn: RewriteFn,
23+
) {
24+
const changeTracker = new ChangeTracker(ts.createPrinter());
25+
const phaseEnum = getImportSpecifier(sourceFile, CORE, AFTER_RENDER_PHASE_ENUM);
26+
27+
// Check if there are any imports of the `AfterRenderPhase` enum.
28+
if (phaseEnum) {
29+
// Remove the `AfterRenderPhase` enum import.
30+
changeTracker.removeNode(phaseEnum);
31+
ts.forEachChild(sourceFile, function visit(node: ts.Node) {
32+
ts.forEachChild(node, visit);
33+
34+
// Check if this is a function call of `afterRender` or `afterNextRender`.
35+
if (
36+
ts.isCallExpression(node) &&
37+
ts.isIdentifier(node.expression) &&
38+
AFTER_RENDER_FNS.has(getImportOfIdentifier(typeChecker, node.expression)?.name || '')
39+
) {
40+
let phase: string | undefined;
41+
const [callback, options] = node.arguments;
42+
// Check if any `AfterRenderOptions` options were specified.
43+
if (ts.isObjectLiteralExpression(options)) {
44+
const phaseProp = options.properties.find((p) => p.name?.getText() === 'phase');
45+
// Check if the `phase` options is set.
46+
if (
47+
phaseProp &&
48+
ts.isPropertyAssignment(phaseProp) &&
49+
ts.isPropertyAccessExpression(phaseProp.initializer) &&
50+
phaseProp.initializer.expression.getText() === AFTER_RENDER_PHASE_ENUM
51+
) {
52+
phaseProp.initializer.expression;
53+
phase = phaseProp.initializer.name.getText();
54+
// Remove the `phase` option.
55+
if (options.properties.length === 1) {
56+
changeTracker.removeNode(options);
57+
} else {
58+
const newOptions = ts.factory.createObjectLiteralExpression(
59+
options.properties.filter((p) => p !== phaseProp),
60+
);
61+
changeTracker.replaceNode(options, newOptions);
62+
}
63+
}
64+
}
65+
// If we found a phase, update the callback.
66+
if (phase) {
67+
phase = phase.substring(0, 1).toLocaleLowerCase() + phase.substring(1);
68+
const spec = ts.factory.createObjectLiteralExpression([
69+
ts.factory.createPropertyAssignment(ts.factory.createIdentifier(phase), callback),
70+
]);
71+
changeTracker.replaceNode(callback, spec);
72+
}
73+
}
74+
});
75+
}
76+
77+
// Write the changes.
78+
for (const changesInFile of changeTracker.recordChanges().values()) {
79+
for (const change of changesInFile) {
80+
rewriteFn(change.start, change.removeLength ?? 0, change.text);
81+
}
82+
}
83+
}

packages/core/schematics/test/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ jasmine_node_test(
1919
data = [
2020
"//packages/core/schematics:collection.json",
2121
"//packages/core/schematics:migrations.json",
22+
"//packages/core/schematics/migrations/after-render-phase",
23+
"//packages/core/schematics/migrations/after-render-phase:bundle",
2224
"//packages/core/schematics/migrations/http-providers",
2325
"//packages/core/schematics/migrations/http-providers:bundle",
2426
"//packages/core/schematics/migrations/invalid-two-way-bindings",
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core';
10+
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
11+
import {HostTree} from '@angular-devkit/schematics';
12+
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
13+
import {runfiles} from '@bazel/runfiles';
14+
import shx from 'shelljs';
15+
16+
describe('afterRender phase migration', () => {
17+
let runner: SchematicTestRunner;
18+
let host: TempScopedNodeJsSyncHost;
19+
let tree: UnitTestTree;
20+
let tmpDirPath: string;
21+
let previousWorkingDir: string;
22+
23+
function writeFile(filePath: string, contents: string) {
24+
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
25+
}
26+
27+
function runMigration() {
28+
return runner.runSchematic('migration-after-render-phase', {}, tree);
29+
}
30+
31+
beforeEach(() => {
32+
runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json'));
33+
host = new TempScopedNodeJsSyncHost();
34+
tree = new UnitTestTree(new HostTree(host));
35+
36+
writeFile(
37+
'/tsconfig.json',
38+
JSON.stringify({
39+
compilerOptions: {
40+
lib: ['es2015'],
41+
strictNullChecks: true,
42+
},
43+
}),
44+
);
45+
46+
writeFile(
47+
'/angular.json',
48+
JSON.stringify({
49+
version: 1,
50+
projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}},
51+
}),
52+
);
53+
54+
previousWorkingDir = shx.pwd();
55+
tmpDirPath = getSystemPath(host.root);
56+
57+
// Switch into the temporary directory path. This allows us to run
58+
// the schematic against our custom unit test tree.
59+
shx.cd(tmpDirPath);
60+
});
61+
62+
it('should update afterRender phase flag', async () => {
63+
writeFile(
64+
'/index.ts',
65+
`
66+
import { AfterRenderPhase, Directive, afterRender } from '@angular/core';
67+
68+
@Directive({
69+
selector: '[someDirective]'
70+
})
71+
export class SomeDirective {
72+
constructor() {
73+
afterRender(() => {
74+
console.log('read');
75+
}, {phase: AfterRenderPhase.Read});
76+
}
77+
}`,
78+
);
79+
80+
await runMigration();
81+
82+
const content = tree.readContent('/index.ts').replace(/\s+/g, ' ');
83+
expect(content).not.toContain('AfterRenderPhase');
84+
expect(content).toContain(`afterRender({ read: () => { console.log('read'); } }, );`);
85+
});
86+
87+
it('should update afterNextRender phase flag', async () => {
88+
writeFile(
89+
'/index.ts',
90+
`
91+
import { AfterRenderPhase, Directive, afterNextRender } from '@angular/core';
92+
93+
@Directive({
94+
selector: '[someDirective]'
95+
})
96+
export class SomeDirective {
97+
constructor() {
98+
afterNextRender(() => {
99+
console.log('earlyRead');
100+
}, {phase: AfterRenderPhase.EarlyRead});
101+
}
102+
}`,
103+
);
104+
105+
await runMigration();
106+
107+
const content = tree.readContent('/index.ts').replace(/\s+/g, ' ');
108+
expect(content).not.toContain('AfterRenderPhase');
109+
expect(content).toContain(
110+
`afterNextRender({ earlyRead: () => { console.log('earlyRead'); } }, );`,
111+
);
112+
});
113+
114+
it('should not update calls that do not specify phase flag', async () => {
115+
const originalContent = `
116+
import { Directive, Injector, afterRender, afterNextRender, inject } from '@angular/core';
117+
118+
@Directive({
119+
selector: '[someDirective]'
120+
})
121+
export class SomeDirective {
122+
injector = inject(Injector);
123+
124+
constructor() {
125+
afterRender(() => {
126+
console.log('default phase');
127+
});
128+
afterNextRender(() => {
129+
console.log('default phase');
130+
});
131+
afterRender(() => {
132+
console.log('default phase');
133+
}, {injector: this.injector});
134+
afterNextRender(() => {
135+
console.log('default phase');
136+
}, {injector: this.injector});
137+
}
138+
}`;
139+
writeFile('/index.ts', originalContent);
140+
141+
await runMigration();
142+
143+
const content = tree.readContent('/index.ts').replace(/\s+/g, ' ');
144+
expect(content).toEqual(originalContent.replace(/\s+/g, ' '));
145+
});
146+
147+
it('should not change options other than phase', async () => {
148+
writeFile(
149+
'/index.ts',
150+
`
151+
import { AfterRenderPhase, Directive, Injector, afterRender, inject } from '@angular/core';
152+
153+
@Directive({
154+
selector: '[someDirective]'
155+
})
156+
export class SomeDirective {
157+
injector = inject(Injector);
158+
159+
constructor() {
160+
afterRender(() => {
161+
console.log('earlyRead');
162+
}, {
163+
phase: AfterRenderPhase.EarlyRead,
164+
injector: this.injector
165+
});
166+
}
167+
}`,
168+
);
169+
170+
await runMigration();
171+
const content = tree.readContent('/index.ts').replace(/\s+/g, ' ');
172+
expect(content).toContain(
173+
`afterRender({ earlyRead: () => { console.log('earlyRead'); } }, { injector: this.injector });`,
174+
);
175+
});
176+
});

0 commit comments

Comments
 (0)