Skip to content

Commit 5e5dac2

Browse files
committed
feat(migrations): Migration to remove Router guard and resolver interfaces (#49337)
The class-based guard and resolver interfaces are deprecated. The `Router` types only support functional guards definitions. Classes can still be used as the underlying implementation of functional guards and resolvers but there will not be an interface requiring a specific structure for those classes. There are also helper functions like `mapToCanActivate` that allow converting the existing class-based guards directly to functional guards at the route definition. This will be done in a separate migration. PR Close #49337
1 parent 22688b8 commit 5e5dac2

File tree

12 files changed

+559
-8
lines changed

12 files changed

+559
-8
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pkg_npm(
1717
validate = False,
1818
visibility = ["//packages/core:__pkg__"],
1919
deps = [
20+
"//packages/core/schematics/migrations/guard-and-resolve-interfaces:bundle",
2021
"//packages/core/schematics/migrations/relative-link-resolution:bundle",
2122
"//packages/core/schematics/migrations/remove-module-id:bundle",
2223
"//packages/core/schematics/migrations/router-link-with-href:bundle",

packages/core/schematics/migrations.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
"version": "16.0.0",
1515
"description": "As of Angular v16, the `moduleId` property of `@Component` is deprecated as it no longer has any effect.",
1616
"factory": "./migrations/remove-module-id/bundle"
17+
},
18+
"migration-v16-guard-and-resolve-interfaces": {
19+
"version": "16.0.0",
20+
"description": "In Angular version 15.2, the guard and resolver interfaces (CanActivate, Resolve, etc) are deprecated. This migration removes imports and 'implements' clauses that contain them.",
21+
"factory": "./migrations/guard-and-resolve-interfaces/bundle"
1722
}
1823
}
19-
}
24+
}

packages/core/schematics/migrations/google3/BUILD.bazel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ts_library(
55
srcs = glob(["**/*.ts"]),
66
tsconfig = "//packages/core/schematics:tsconfig.json",
77
deps = [
8+
"//packages/core/schematics/migrations/guard-and-resolve-interfaces",
89
"//packages/core/schematics/migrations/relative-link-resolution",
910
"//packages/core/schematics/utils",
1011
"//packages/core/schematics/utils/tslint",
@@ -22,6 +23,15 @@ esbuild(
2223
deps = [":google3"],
2324
)
2425

26+
esbuild(
27+
name = "guard_and_resolve_interfaces_cjs",
28+
entry_point = ":guardAndResolveInterfacesRule.ts",
29+
format = "cjs",
30+
output = "guardAndResolveInterfacesCjsRule.js",
31+
platform = "node",
32+
deps = [":google3"],
33+
)
34+
2535
esbuild(
2636
name = "wait_for_async_rule_cjs",
2737
entry_point = ":waitForAsyncRule.ts",
@@ -34,6 +44,7 @@ esbuild(
3444
filegroup(
3545
name = "google3_cjs",
3646
srcs = [
47+
":guard_and_resolve_interfaces_cjs",
3748
":relative_link_resolution_cjs",
3849
":wait_for_async_rule_cjs",
3950
],
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 {Replacement, RuleFailure, Rules} from 'tslint';
10+
import ts from 'typescript';
11+
12+
import {migrateFile} from '../guard-and-resolve-interfaces/util';
13+
14+
/** TSLint rule for the guard and resolve interfaces migration. */
15+
export class Rule extends Rules.TypedRule {
16+
override applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
17+
const failures: RuleFailure[] = [];
18+
19+
const rewriter = (startPos: number, origLength: number, text: string) => {
20+
const failure = new RuleFailure(
21+
sourceFile, startPos, startPos + origLength,
22+
'The guard and resolve interfaces in the Angular router are being removed.',
23+
this.ruleName, new Replacement(startPos, origLength, text));
24+
failures.push(failure);
25+
};
26+
27+
migrateFile(sourceFile, program.getTypeChecker(), rewriter);
28+
29+
return failures;
30+
}
31+
}
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 = "guard-and-resolve-interfaces",
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 = [":guard-and-resolve-interfaces"],
33+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Guard and resolver interfaces migration
2+
3+
Since Angular v15.2, the `Router` guard and resolver interfaces have been deprecated.
4+
Injectable classes can still be injected at the `Route` definition, but the `Router`
5+
will not export interfaces that define a specific shape for those classes. Instead,
6+
the guards and resolvers on the Route can inject the class and call whatever method
7+
they want, regardless of the guard name.
8+
9+
#### Before
10+
```ts
11+
import { Injectable } from '@angular/router';
12+
import { CanActivate } from '@angular/router';
13+
14+
@Injectable({providedIn: 'root'})
15+
export class MyGuard implements CanActivate {
16+
canActivate() {
17+
return true;
18+
}
19+
}
20+
```
21+
22+
#### After
23+
```ts
24+
import { Injectable } from '@angular/router';
25+
26+
@Injectable({providedIn: 'root'})
27+
export class MyGuard {
28+
canActivate() {
29+
return true;
30+
}
31+
}
32+
```
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
12+
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
13+
import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host';
14+
15+
import {migrateFile} from './util';
16+
17+
export default function(): Rule {
18+
return async (tree: Tree) => {
19+
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
20+
const basePath = process.cwd();
21+
const allPaths = [...buildPaths, ...testPaths];
22+
23+
if (!allPaths.length) {
24+
throw new SchematicsException(
25+
'Could not find any tsconfig file. Cannot run the guard and resolve interfaces migration.');
26+
}
27+
28+
for (const tsconfigPath of allPaths) {
29+
runGuardAndResolveInterfacesMigration(tree, tsconfigPath, basePath);
30+
}
31+
};
32+
}
33+
34+
function runGuardAndResolveInterfacesMigration(tree: Tree, tsconfigPath: string, basePath: string) {
35+
const program = createMigrationProgram(tree, tsconfigPath, basePath);
36+
const typeChecker = program.getTypeChecker();
37+
const sourceFiles =
38+
program.getSourceFiles().filter(sourceFile => canMigrateFile(basePath, sourceFile, program));
39+
40+
for (const sourceFile of sourceFiles) {
41+
let update: UpdateRecorder|null = null;
42+
43+
const rewriter = (startPos: number, width: number, text: string|null) => {
44+
if (update === null) {
45+
// Lazily initialize update, because most files will not require migration.
46+
update = tree.beginUpdate(relative(basePath, sourceFile.fileName));
47+
}
48+
update.remove(startPos, width);
49+
if (text !== null) {
50+
update.insertLeft(startPos, text);
51+
}
52+
};
53+
migrateFile(sourceFile, typeChecker, rewriter);
54+
55+
if (update !== null) {
56+
tree.commitUpdate(update);
57+
}
58+
}
59+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
11+
import {ChangeTracker} from '../../utils/change_tracker';
12+
import {getImportOfIdentifier, getImportSpecifier, getImportSpecifiers, removeSymbolFromNamedImports, replaceImport} from '../../utils/typescript/imports';
13+
import {closestNode} from '../../utils/typescript/nodes';
14+
15+
export const deprecatedInterfaces =
16+
new Set(['CanLoad', 'CanMatch', 'CanActivate', 'CanDeactivate', 'CanActivateChild', 'Resolve']);
17+
export const routerModule = '@angular/router';
18+
19+
export type RewriteFn = (startPos: number, width: number, text: string) => void;
20+
21+
export function migrateFile(
22+
sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker, rewriteFn: RewriteFn) {
23+
const deprecatedImports =
24+
getImportSpecifiers(sourceFile, routerModule, Array.from(deprecatedInterfaces));
25+
if (deprecatedImports.length === 0) {
26+
return;
27+
}
28+
const changeTracker = new ChangeTracker(ts.createPrinter());
29+
// Map of original named imports to the most recent migrated node. We might update it multiple
30+
// times so we need to accumulate updates.
31+
const updatedImports = new Map<ts.NamedImports, ts.NamedImports>();
32+
const updatedImplements = new Map<ts.HeritageClause, ts.HeritageClause>();
33+
34+
findUsages(
35+
sourceFile, typeChecker, updatedImplements, updatedImports, changeTracker, deprecatedImports);
36+
findImports(sourceFile, updatedImports);
37+
38+
for (const [originalNode, rewrittenNode] of updatedImports.entries()) {
39+
if (rewrittenNode.elements.length > 0) {
40+
changeTracker.replaceNode(originalNode, rewrittenNode);
41+
} else {
42+
const importDeclaration = originalNode.parent.parent;
43+
changeTracker.removeNode(importDeclaration);
44+
}
45+
}
46+
47+
for (const [originalNode, rewrittenNode] of updatedImplements.entries()) {
48+
if (rewrittenNode.types.length > 0) {
49+
changeTracker.replaceNode(originalNode, rewrittenNode);
50+
} else {
51+
changeTracker.removeNode(originalNode);
52+
}
53+
}
54+
55+
for (const changesInFile of changeTracker.recordChanges().values()) {
56+
for (const change of changesInFile) {
57+
rewriteFn(change.start, change.removeLength ?? 0, change.text);
58+
}
59+
}
60+
}
61+
62+
function findImports(
63+
sourceFile: ts.SourceFile, updatedImports: Map<ts.NamedImports, ts.NamedImports>) {
64+
for (const deprecatedInterface of deprecatedInterfaces) {
65+
const importSpecifier = getImportSpecifier(sourceFile, routerModule, deprecatedInterface);
66+
67+
// No `specifier` found, nothing to migrate, exit early.
68+
if (importSpecifier === null) continue;
69+
70+
const namedImports = closestNode(importSpecifier, ts.isNamedImports);
71+
if (namedImports !== null) {
72+
const importToUpdate = updatedImports.get(namedImports) ?? namedImports;
73+
const rewrittenNamedImports = removeSymbolFromNamedImports(importToUpdate, importSpecifier);
74+
updatedImports.set(namedImports, rewrittenNamedImports);
75+
}
76+
}
77+
}
78+
79+
function findUsages(
80+
sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker,
81+
updatedImplements: Map<ts.HeritageClause, ts.HeritageClause>,
82+
updatedImports: Map<ts.NamedImports, ts.NamedImports>, changeTracker: ChangeTracker,
83+
deprecatedImports: ts.ImportSpecifier[]): void {
84+
const visitNode = (node: ts.Node) => {
85+
if (ts.isImportSpecifier(node)) {
86+
// Skip this node and all of its children; imports are a special case.
87+
return;
88+
}
89+
if ((ts.isInterfaceDeclaration(node) || ts.isClassLike(node)) && node.heritageClauses) {
90+
for (const heritageClause of node.heritageClauses) {
91+
visitHeritageClause(heritageClause, typeChecker, updatedImplements, deprecatedImports);
92+
}
93+
ts.forEachChild(node, visitNode);
94+
} else if (ts.isTypeReferenceNode(node)) {
95+
visitTypeReference(
96+
node, typeChecker, changeTracker, sourceFile, updatedImports, deprecatedImports);
97+
} else {
98+
ts.forEachChild(node, visitNode);
99+
}
100+
};
101+
ts.forEachChild(sourceFile, visitNode);
102+
}
103+
104+
function visitHeritageClause(
105+
heritageClause: ts.HeritageClause, typeChecker: ts.TypeChecker,
106+
updatedImplements: Map<ts.HeritageClause, ts.HeritageClause>,
107+
deprecatedImports: ts.ImportSpecifier[]) {
108+
const visitChildren = (node: ts.Node): void => {
109+
if (ts.isIdentifier(node)) {
110+
if (deprecatedImports.some(importSpecifier => importSpecifier.name.text === node.text)) {
111+
const importIdentifier = getImportOfIdentifier(typeChecker, node);
112+
if (importIdentifier?.importModule === routerModule &&
113+
deprecatedInterfaces.has(importIdentifier.name)) {
114+
const heritageClauseToUpdate = updatedImplements.get(heritageClause) ?? heritageClause;
115+
const mostRecentUpdate = ts.factory.updateHeritageClause(
116+
heritageClauseToUpdate, heritageClauseToUpdate.types.filter(current => {
117+
return !ts.isExpressionWithTypeArguments(current) || current.expression !== node;
118+
}));
119+
updatedImplements.set(heritageClause, mostRecentUpdate);
120+
}
121+
}
122+
}
123+
ts.forEachChild(node, visitChildren);
124+
};
125+
ts.forEachChild(heritageClause, visitChildren);
126+
}
127+
128+
function visitTypeReference(
129+
typeReference: ts.TypeReferenceNode, typeChecker: ts.TypeChecker, changeTracker: ChangeTracker,
130+
sourceFile: ts.SourceFile, updatedImports: Map<ts.NamedImports, ts.NamedImports>,
131+
deprecatedImports: ts.ImportSpecifier[]) {
132+
const visitTypeReferenceChildren = (node: ts.Node): void => {
133+
if (ts.isIdentifier(node) &&
134+
deprecatedImports.some(importSpecifier => importSpecifier.name.text === node.text)) {
135+
const importIdentifier = getImportOfIdentifier(typeChecker, node);
136+
if (importIdentifier?.importModule === routerModule &&
137+
deprecatedInterfaces.has(importIdentifier.name)) {
138+
const {name: interfaceName} = importIdentifier;
139+
const functionTypeName = `${interfaceName}Fn`;
140+
const classFunctionName =
141+
`${interfaceName.charAt(0).toLocaleLowerCase()}${interfaceName.slice(1)}`;
142+
// i.e. Resolve<T> => {resolve: ResolveFn<T>}
143+
const replacement = ts.factory.createTypeLiteralNode([ts.factory.createPropertySignature(
144+
undefined,
145+
ts.factory.createIdentifier(classFunctionName),
146+
undefined,
147+
ts.factory.createTypeReferenceNode(
148+
ts.factory.createIdentifier(functionTypeName),
149+
ts.isTypeReferenceNode(node.parent) ? node.parent.typeArguments : undefined,
150+
),
151+
)]);
152+
changeTracker.replaceNode(node.parent, replacement);
153+
const importSpecifier = getImportSpecifier(sourceFile, routerModule, interfaceName);
154+
155+
// No `specifier` found, nothing to migrate, exit early.
156+
if (importSpecifier === null) return;
157+
158+
const namedImports = closestNode(importSpecifier, ts.isNamedImports);
159+
if (namedImports !== null) {
160+
const importToUpdate = updatedImports.get(namedImports) ?? namedImports;
161+
const rewrittenNamedImports =
162+
replaceImport(importToUpdate, interfaceName, functionTypeName);
163+
updatedImports.set(namedImports, rewrittenNamedImports);
164+
}
165+
}
166+
}
167+
ts.forEachChild(node, visitTypeReferenceChildren);
168+
};
169+
ts.forEachChild(typeReference, visitTypeReferenceChildren);
170+
}

packages/core/schematics/migrations/router-link-with-href/util.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ export function migrateFile(
3939

4040
if (routerLinkSpec) {
4141
// The `RouterLink` symbol is already imported, just drop the `RouterLinkWithHref` one.
42-
const routerLinkNamedImports =
43-
routerLinkWithHrefSpec ? closestNode(routerLinkWithHrefSpec, ts.isNamedImports) : null;
42+
const routerLinkNamedImports = closestNode(routerLinkWithHrefSpec, ts.isNamedImports);
4443
if (routerLinkNamedImports !== null) {
4544
// Given an original import like this one:
4645
// ```

0 commit comments

Comments
 (0)