Skip to content

Commit 0ec7043

Browse files
CaerusKaruatscott
authored andcommitted
feat(core): add initialNavigation schematic (angular#36926)
Add a schematic to update users to the new v11 `initialNavigation` options for `RouterModule`. This replaces the deprecated/removed `true`, `false`, `legacy_disabled`, and `legacy_enabled` options with the newer `enabledBlocking` and `enabledNonBlocking` options. PR Close angular#36926
1 parent c4becca commit 0ec7043

File tree

17 files changed

+932
-1
lines changed

17 files changed

+932
-1
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pkg_npm(
1212
deps = [
1313
"//packages/core/schematics/migrations/abstract-control-parent",
1414
"//packages/core/schematics/migrations/dynamic-queries",
15+
"//packages/core/schematics/migrations/initial-navigation",
1516
"//packages/core/schematics/migrations/missing-injectable",
1617
"//packages/core/schematics/migrations/module-with-providers",
1718
"//packages/core/schematics/migrations/move-document",

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@
7474
"version": "11.0.0-beta",
7575
"description": "NavigationExtras.preserveQueryParams has been removed as of Angular version 11. This migration replaces any usages with the appropriate assignment of the queryParamsHandler key.",
7676
"factory": "./migrations/router-preserve-query-params/index"
77+
},
78+
"migration-v11-router-initial-navigation-options": {
79+
"version": "11.0.0-beta",
80+
"description": "Updates the `initialNavigation` property for `RouterModule.forRoot`.",
81+
"factory": "./migrations/initial-navigation/index"
7782
}
7883
}
7984
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ ts_library(
77
visibility = ["//packages/core/schematics/test/google3:__pkg__"],
88
deps = [
99
"//packages/core/schematics/migrations/dynamic-queries",
10+
"//packages/core/schematics/migrations/initial-navigation",
11+
"//packages/core/schematics/migrations/initial-navigation/google3",
1012
"//packages/core/schematics/migrations/missing-injectable",
1113
"//packages/core/schematics/migrations/missing-injectable/google3",
1214
"//packages/core/schematics/migrations/navigation-extras-omissions",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 {RuleFailure, Rules} from 'tslint';
10+
import * as ts from 'typescript';
11+
12+
import {InitialNavigationCollector} from '../initial-navigation/collector';
13+
import {TslintUpdateRecorder} from '../initial-navigation/google3/tslint_update_recorder';
14+
import {InitialNavigationTransform} from '../initial-navigation/transform';
15+
16+
17+
18+
/**
19+
* TSLint rule that updates RouterModule `forRoot` options to be in line with v10 updates.
20+
*/
21+
export class Rule extends Rules.TypedRule {
22+
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
23+
const ruleName = this.ruleName;
24+
const typeChecker = program.getTypeChecker();
25+
const sourceFiles = program.getSourceFiles().filter(
26+
s => !s.isDeclarationFile && !program.isSourceFileFromExternalLibrary(s));
27+
const initialNavigationCollector = new InitialNavigationCollector(typeChecker);
28+
const failures: RuleFailure[] = [];
29+
30+
// Analyze source files by detecting all ExtraOptions#InitialNavigation assignments
31+
sourceFiles.forEach(sourceFile => initialNavigationCollector.visitNode(sourceFile));
32+
33+
const {assignments} = initialNavigationCollector;
34+
const transformer = new InitialNavigationTransform(typeChecker, getUpdateRecorder);
35+
const updateRecorders = new Map<ts.SourceFile, TslintUpdateRecorder>();
36+
37+
transformer.migrateInitialNavigationAssignments(Array.from(assignments));
38+
39+
if (updateRecorders.has(sourceFile)) {
40+
failures.push(...updateRecorders.get(sourceFile)!.failures);
41+
}
42+
43+
return failures;
44+
45+
/** Gets the update recorder for the specified source file. */
46+
function getUpdateRecorder(sourceFile: ts.SourceFile): TslintUpdateRecorder {
47+
if (updateRecorders.has(sourceFile)) {
48+
return updateRecorders.get(sourceFile)!;
49+
}
50+
const recorder = new TslintUpdateRecorder(ruleName, sourceFile);
51+
updateRecorders.set(sourceFile, recorder);
52+
return recorder;
53+
}
54+
}
55+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
ts_library(
4+
name = "initial-navigation",
5+
srcs = glob(["**/*.ts"]),
6+
tsconfig = "//packages/core/schematics:tsconfig.json",
7+
visibility = [
8+
"//packages/core/schematics:__pkg__",
9+
"//packages/core/schematics/migrations/google3:__pkg__",
10+
"//packages/core/schematics/migrations/initial-navigation/google3:__pkg__",
11+
"//packages/core/schematics/test:__pkg__",
12+
],
13+
deps = [
14+
"//packages/core/schematics/utils",
15+
"@npm//@angular-devkit/schematics",
16+
"@npm//@types/node",
17+
"@npm//typescript",
18+
],
19+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## initialNavigation migration
2+
3+
Automatically migrates the `initialNavigation` property of the `RouterModule` to the newly
4+
available options: `enabledNonBlocking` (default), `enabledBlocking`, and `disabled`.
5+
6+
#### Before
7+
```ts
8+
import { NgModule } from '@angular/core';
9+
import { RouterModule } from '@angular/router';
10+
11+
@NgModule({
12+
imports: [
13+
RouterModule.forRoot(ROUTES, {initialNavigation: 'legacy_disabled'}),
14+
]
15+
})
16+
export class AppModule {
17+
}
18+
```
19+
20+
#### After
21+
```ts
22+
import { NgModule } from '@angular/core';
23+
import { RouterModule } from '@angular/router';
24+
25+
@NgModule({
26+
imports: [
27+
RouterModule.forRoot(ROUTES, {initialNavigation: 'disabled'}),
28+
]
29+
})
30+
export class AppModule {
31+
}
32+
```
33+
34+
### Disclaimer
35+
36+
The migration only covers the most common patterns where developers set the `ExtraOptions#InitialNavigation`
37+
option to an outdated value. Therefore, if a user declares the option using a number of other methods,
38+
e.g. shorthand notation, variable declaration, or some other crafty method, they will have to migrate
39+
those options by hand. Otherwise, the compiler will error if the types are sufficiently enforced.
40+
41+
The basic migration strategy is as follows:
42+
* `legacy_disabled` || `false` => `disabled`
43+
* `legacy_enabled` || `true` => `enabledNonBlocking` (new default)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
import * as ts from 'typescript';
9+
import {isExtraOptions, isRouterModuleForRoot} from './util';
10+
11+
12+
/** The property name for the options that need to be migrated */
13+
const INITIAL_NAVIGATION = 'initialNavigation';
14+
15+
/**
16+
* Visitor that walks through specified TypeScript nodes and collects all
17+
* found ExtraOptions#InitialNavigation assignments.
18+
*/
19+
export class InitialNavigationCollector {
20+
public assignments: Set<ts.PropertyAssignment> = new Set();
21+
22+
constructor(private readonly typeChecker: ts.TypeChecker) {}
23+
24+
visitNode(node: ts.Node) {
25+
let extraOptionsLiteral: ts.ObjectLiteralExpression|null = null;
26+
if (isRouterModuleForRoot(this.typeChecker, node) && node.arguments.length > 0) {
27+
if (node.arguments.length === 1) {
28+
return;
29+
}
30+
31+
if (ts.isObjectLiteralExpression(node.arguments[1])) {
32+
extraOptionsLiteral = node.arguments[1] as ts.ObjectLiteralExpression;
33+
} else if (ts.isIdentifier(node.arguments[1])) {
34+
extraOptionsLiteral =
35+
this.getLiteralNeedingMigrationFromIdentifier(node.arguments[1] as ts.Identifier);
36+
}
37+
} else if (ts.isVariableDeclaration(node)) {
38+
extraOptionsLiteral = this.getLiteralNeedingMigration(node);
39+
}
40+
41+
if (extraOptionsLiteral !== null) {
42+
this.visitExtraOptionsLiteral(extraOptionsLiteral);
43+
} else {
44+
// no match found, continue iteration
45+
ts.forEachChild(node, n => this.visitNode(n));
46+
}
47+
}
48+
49+
visitExtraOptionsLiteral(extraOptionsLiteral: ts.ObjectLiteralExpression) {
50+
for (const prop of extraOptionsLiteral.properties) {
51+
if (ts.isPropertyAssignment(prop) &&
52+
(ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name))) {
53+
if (prop.name.text === INITIAL_NAVIGATION && isValidInitialNavigationValue(prop)) {
54+
this.assignments.add(prop);
55+
}
56+
} else if (ts.isSpreadAssignment(prop) && ts.isIdentifier(prop.expression)) {
57+
const literalFromSpreadAssignment =
58+
this.getLiteralNeedingMigrationFromIdentifier(prop.expression);
59+
if (literalFromSpreadAssignment !== null) {
60+
this.visitExtraOptionsLiteral(literalFromSpreadAssignment);
61+
}
62+
}
63+
}
64+
}
65+
66+
private getLiteralNeedingMigrationFromIdentifier(id: ts.Identifier): ts.ObjectLiteralExpression
67+
|null {
68+
const symbolForIdentifier = this.typeChecker.getSymbolAtLocation(id);
69+
if (symbolForIdentifier === undefined) {
70+
return null;
71+
}
72+
73+
if (symbolForIdentifier.declarations.length === 0) {
74+
return null;
75+
}
76+
77+
const declarationNode = symbolForIdentifier.declarations[0];
78+
if (!ts.isVariableDeclaration(declarationNode) || declarationNode.initializer === undefined ||
79+
!ts.isObjectLiteralExpression(declarationNode.initializer)) {
80+
return null;
81+
}
82+
83+
return declarationNode.initializer;
84+
}
85+
86+
private getLiteralNeedingMigration(node: ts.VariableDeclaration): ts.ObjectLiteralExpression
87+
|null {
88+
if (node.initializer === undefined) {
89+
return null;
90+
}
91+
92+
// declaration could be `x: ExtraOptions = {}` or `x = {} as ExtraOptions`
93+
if (ts.isAsExpression(node.initializer) &&
94+
ts.isObjectLiteralExpression(node.initializer.expression) &&
95+
isExtraOptions(this.typeChecker, node.initializer.type)) {
96+
return node.initializer.expression;
97+
} else if (
98+
node.type !== undefined && ts.isObjectLiteralExpression(node.initializer) &&
99+
isExtraOptions(this.typeChecker, node.type)) {
100+
return node.initializer;
101+
}
102+
103+
return null;
104+
}
105+
}
106+
107+
/**
108+
* Check whether the value assigned to an `initialNavigation` assignment
109+
* conforms to the expected types for ExtraOptions#InitialNavigation
110+
* @param node the property assignment to check
111+
*/
112+
function isValidInitialNavigationValue(node: ts.PropertyAssignment): boolean {
113+
return ts.isStringLiteralLike(node.initializer) ||
114+
node.initializer.kind === ts.SyntaxKind.FalseKeyword ||
115+
node.initializer.kind === ts.SyntaxKind.TrueKeyword;
116+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
ts_library(
4+
name = "google3",
5+
srcs = glob(["**/*.ts"]),
6+
tsconfig = "//packages/core/schematics:tsconfig.json",
7+
visibility = ["//packages/core/schematics/migrations/google3:__pkg__"],
8+
deps = [
9+
"//packages/core/schematics/migrations/initial-navigation",
10+
"@npm//tslint",
11+
"@npm//typescript",
12+
],
13+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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} from 'tslint';
10+
import * as ts from 'typescript';
11+
12+
import {UpdateRecorder} from '../update_recorder';
13+
14+
export class TslintUpdateRecorder implements UpdateRecorder {
15+
failures: RuleFailure[] = [];
16+
17+
constructor(private ruleName: string, private sourceFile: ts.SourceFile) {}
18+
19+
updateNode(node: ts.Node, newText: string): void {
20+
this.failures.push(new RuleFailure(
21+
this.sourceFile, node.getStart(), node.getEnd(), `Node needs to be updated to: ${newText}`,
22+
this.ruleName, Replacement.replaceFromTo(node.getStart(), node.getEnd(), newText)));
23+
}
24+
25+
commitUpdate() {}
26+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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} from '@angular-devkit/schematics';
10+
import {relative} from 'path';
11+
import * as ts from 'typescript';
12+
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
13+
import {createMigrationProgram} from '../../utils/typescript/compiler_host';
14+
import {InitialNavigationCollector} from './collector';
15+
import {InitialNavigationTransform} from './transform';
16+
import {UpdateRecorder} from './update_recorder';
17+
18+
/** Entry point for the v10 "initialNavigation RouterModule options" schematic. */
19+
export default function(): Rule {
20+
return (tree: Tree) => {
21+
const {buildPaths, testPaths} = getProjectTsConfigPaths(tree);
22+
const basePath = process.cwd();
23+
24+
if (!buildPaths.length && !testPaths.length) {
25+
throw new SchematicsException(
26+
'Could not find any tsconfig file. Cannot update the "initialNavigation" option for RouterModule');
27+
}
28+
29+
for (const tsconfigPath of [...buildPaths, ...testPaths]) {
30+
runInitialNavigationMigration(tree, tsconfigPath, basePath);
31+
}
32+
};
33+
}
34+
35+
function runInitialNavigationMigration(tree: Tree, tsconfigPath: string, basePath: string) {
36+
const {program} = createMigrationProgram(tree, tsconfigPath, basePath);
37+
const typeChecker = program.getTypeChecker();
38+
const initialNavigationCollector = new InitialNavigationCollector(typeChecker);
39+
const sourceFiles = program.getSourceFiles().filter(
40+
f => !f.isDeclarationFile && !program.isSourceFileFromExternalLibrary(f));
41+
42+
// Analyze source files by detecting all modules.
43+
sourceFiles.forEach(sourceFile => initialNavigationCollector.visitNode(sourceFile));
44+
45+
const {assignments} = initialNavigationCollector;
46+
const transformer = new InitialNavigationTransform(typeChecker, getUpdateRecorder);
47+
const updateRecorders = new Map<ts.SourceFile, UpdateRecorder>();
48+
transformer.migrateInitialNavigationAssignments(Array.from(assignments));
49+
50+
// Walk through each update recorder and commit the update. We need to commit the
51+
// updates in batches per source file as there can be only one recorder per source
52+
// file in order to avoid shift character offsets.
53+
updateRecorders.forEach(recorder => recorder.commitUpdate());
54+
55+
/** Gets the update recorder for the specified source file. */
56+
function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder {
57+
if (updateRecorders.has(sourceFile)) {
58+
return updateRecorders.get(sourceFile)!;
59+
}
60+
const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName));
61+
const recorder: UpdateRecorder = {
62+
updateNode(node: ts.Node, newText: string) {
63+
treeRecorder.remove(node.getStart(), node.getWidth());
64+
treeRecorder.insertRight(node.getStart(), newText);
65+
},
66+
commitUpdate() {
67+
tree.commitUpdate(treeRecorder);
68+
}
69+
};
70+
updateRecorders.set(sourceFile, recorder);
71+
return recorder;
72+
}
73+
}

0 commit comments

Comments
 (0)