Skip to content

Commit fb540e1

Browse files
crisbetopkozlowski-opensource
authored andcommitted
feat(core): add migration for invalid two-way bindings (#54630)
As a part of #54154, an old parser behavior came up where two-way bindings were parsed by appending `= $event` to the event side. This was problematic, because it allowed some non-writable expressions to be passed into two-way bindings. These changes introduce a migration that will change the two-way bindings into two separate input/output bindings that represent the old behavior so that in a future version we can throw a parser error for the invalid expressions. ```ts // Before @component({ template: `<input [(ngModel)]="a && b"/>` }) export class MyComp {} // After @component({ template: `<input [ngModel]="a && b" (ngModelChange)="a && (b = $event)"/>` }) export class MyComp {} ``` PR Close #54630
1 parent ae7dbe4 commit fb540e1

File tree

9 files changed

+831
-0
lines changed

9 files changed

+831
-0
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pkg_npm(
2020
deps = [
2121
"//packages/core/schematics/migrations/block-template-entities:bundle",
2222
"//packages/core/schematics/migrations/compiler-options:bundle",
23+
"//packages/core/schematics/migrations/invalid-two-way-bindings:bundle",
2324
"//packages/core/schematics/migrations/transfer-state:bundle",
2425
"//packages/core/schematics/ng-generate/control-flow-migration:bundle",
2526
"//packages/core/schematics/ng-generate/standalone-migration:bundle",

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
"version": "17.0.0",
1515
"description": "Updates `TransferState`, `makeStateKey`, `StateKey` imports from `@angular/platform-browser` to `@angular/core`.",
1616
"factory": "./migrations/transfer-state/bundle"
17+
},
18+
"invalid-two-way-bindings": {
19+
"version": "17.3.0",
20+
"description": "Updates two-way bindings that have an invalid expression to use the longform expression instead.",
21+
"factory": "./migrations/invalid-two-way-bindings/bundle"
1722
}
1823
}
1924
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 = "invalid-two-way-bindings",
13+
srcs = glob(["**/*.ts"]),
14+
tsconfig = "//packages/core/schematics:tsconfig.json",
15+
deps = [
16+
"//packages/compiler",
17+
"//packages/core/schematics/utils",
18+
"@npm//@angular-devkit/schematics",
19+
"@npm//@types/node",
20+
"@npm//typescript",
21+
],
22+
)
23+
24+
esbuild(
25+
name = "bundle",
26+
entry_point = ":index.ts",
27+
external = [
28+
"@angular-devkit/*",
29+
"typescript",
30+
],
31+
format = "cjs",
32+
platform = "node",
33+
deps = [":invalid-two-way-bindings"],
34+
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## Invalid two-way bindings migration
2+
3+
Due to a quirk in the template parser, Angular previously allowed some unassignable expressions
4+
to be passed into two-way bindings which may produce incorrect results. This migration will
5+
replace the invalid two-way bindings with their input/output pair while preserving the original
6+
behavior. Note that the migrated expression may not be the original intent of the code as it was
7+
written, but they match what the Angular runtime would've executed.
8+
9+
The invalid bindings will become errors in a future version of Angular.
10+
11+
Some examples of invalid expressions include:
12+
* Binary expressions like `[(ngModel)]="a || b"`. Previously Angular would append `= $event` to
13+
the right-hand-side of the expression (e.g. `(ngModelChange)="a || (b = $event)"`).
14+
* Unary expressions like `[(ngModel)]="!a"` which Angular would wrap in a parentheses and execute
15+
(e.g. `(ngModelChange)="!(a = $event)"`).
16+
* Conditional expressions like `[(ngModel)]="a ? b : c"` where Angular would add `= $event` to
17+
the false case, e.g. `(ngModelChange)="a ? b : c = $event"`.
18+
19+
#### Before
20+
```ts
21+
import {Component} from '@angular/core';
22+
23+
@Component({
24+
template: `<input [(ngModel)]="a && b"/>`
25+
})
26+
export class MyComp {}
27+
```
28+
29+
30+
#### After
31+
```ts
32+
import {Component} from '@angular/core';
33+
34+
@Component({
35+
template: `<input [ngModel]="a && b" (ngModelChange)="a && (b = $event)"/>`
36+
})
37+
export class MyComp {}
38+
```
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 {dirname, join} from 'path';
10+
import ts from 'typescript';
11+
12+
/**
13+
* Represents a range of text within a file. Omitting the end
14+
* means that it's until the end of the file.
15+
*/
16+
type Range = [start: number, end?: number];
17+
18+
/** Represents a file that was analyzed by the migration. */
19+
export class AnalyzedFile {
20+
private ranges: Range[] = [];
21+
22+
/** Returns the ranges in the order in which they should be migrated. */
23+
getSortedRanges(): Range[] {
24+
return this.ranges.slice().sort(([aStart], [bStart]) => bStart - aStart);
25+
}
26+
27+
/**
28+
* Adds a text range to an `AnalyzedFile`.
29+
* @param path Path of the file.
30+
* @param analyzedFiles Map keeping track of all the analyzed files.
31+
* @param range Range to be added.
32+
*/
33+
static addRange(path: string, analyzedFiles: Map<string, AnalyzedFile>, range: Range): void {
34+
let analysis = analyzedFiles.get(path);
35+
36+
if (!analysis) {
37+
analysis = new AnalyzedFile();
38+
analyzedFiles.set(path, analysis);
39+
}
40+
41+
const duplicate =
42+
analysis.ranges.find(current => current[0] === range[0] && current[1] === range[1]);
43+
44+
if (!duplicate) {
45+
analysis.ranges.push(range);
46+
}
47+
}
48+
}
49+
50+
/**
51+
* Analyzes a source file to find file that need to be migrated and the text ranges within them.
52+
* @param sourceFile File to be analyzed.
53+
* @param analyzedFiles Map in which to store the results.
54+
*/
55+
export function analyze(sourceFile: ts.SourceFile, analyzedFiles: Map<string, AnalyzedFile>) {
56+
forEachClass(sourceFile, node => {
57+
// Note: we have a utility to resolve the Angular decorators from a class declaration already.
58+
// We don't use it here, because it requires access to the type checker which makes it more
59+
// time-consuming to run internally.
60+
const decorator = ts.getDecorators(node)?.find(dec => {
61+
return ts.isCallExpression(dec.expression) && ts.isIdentifier(dec.expression.expression) &&
62+
dec.expression.expression.text === 'Component';
63+
}) as (ts.Decorator & {expression: ts.CallExpression}) |
64+
undefined;
65+
66+
const metadata = decorator && decorator.expression.arguments.length > 0 &&
67+
ts.isObjectLiteralExpression(decorator.expression.arguments[0]) ?
68+
decorator.expression.arguments[0] :
69+
null;
70+
71+
if (!metadata) {
72+
return;
73+
}
74+
75+
for (const prop of metadata.properties) {
76+
// All the properties we care about should have static
77+
// names and be initialized to a static string.
78+
if (!ts.isPropertyAssignment(prop) || !ts.isStringLiteralLike(prop.initializer) ||
79+
(!ts.isIdentifier(prop.name) && !ts.isStringLiteralLike(prop.name))) {
80+
continue;
81+
}
82+
83+
switch (prop.name.text) {
84+
case 'template':
85+
// +1/-1 to exclude the opening/closing characters from the range.
86+
AnalyzedFile.addRange(
87+
sourceFile.fileName, analyzedFiles,
88+
[prop.initializer.getStart() + 1, prop.initializer.getEnd() - 1]);
89+
break;
90+
91+
case 'templateUrl':
92+
// Leave the end as undefined which means that the range is until the end of the file.
93+
const path = join(dirname(sourceFile.fileName), prop.initializer.text);
94+
AnalyzedFile.addRange(path, analyzedFiles, [0]);
95+
break;
96+
}
97+
}
98+
});
99+
}
100+
101+
/** Executes a callback on each class declaration in a file. */
102+
function forEachClass(sourceFile: ts.SourceFile, callback: (node: ts.ClassDeclaration) => void) {
103+
sourceFile.forEachChild(function walk(node) {
104+
if (ts.isClassDeclaration(node)) {
105+
callback(node);
106+
}
107+
node.forEachChild(walk);
108+
});
109+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
12+
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
13+
import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host';
14+
15+
import {analyze, AnalyzedFile} from './analysis';
16+
import {migrateTemplate} from './migration';
17+
18+
export default function(): Rule {
19+
return async (tree: Tree) => {
20+
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
21+
const basePath = process.cwd();
22+
const allPaths = [...buildPaths, ...testPaths];
23+
24+
if (!allPaths.length) {
25+
throw new SchematicsException(
26+
'Could not find any tsconfig file. Cannot run the invalid two-way bindings migration.');
27+
}
28+
29+
for (const tsconfigPath of allPaths) {
30+
runInvalidTwoWayBindingsMigration(tree, tsconfigPath, basePath);
31+
}
32+
};
33+
}
34+
35+
function runInvalidTwoWayBindingsMigration(tree: Tree, tsconfigPath: string, basePath: string) {
36+
const program = createMigrationProgram(tree, tsconfigPath, basePath);
37+
const sourceFiles =
38+
program.getSourceFiles().filter(sourceFile => canMigrateFile(basePath, sourceFile, program));
39+
const analysis = new Map<string, AnalyzedFile>();
40+
41+
for (const sourceFile of sourceFiles) {
42+
analyze(sourceFile, analysis);
43+
}
44+
45+
for (const [path, file] of analysis) {
46+
const ranges = file.getSortedRanges();
47+
const relativePath = relative(basePath, path);
48+
49+
// Don't interrupt the entire migration if a file can't be read.
50+
if (!tree.exists(relativePath)) {
51+
continue;
52+
}
53+
54+
const content = tree.readText(relativePath);
55+
const update = tree.beginUpdate(relativePath);
56+
57+
for (const [start, end] of ranges) {
58+
const template = content.slice(start, end);
59+
const length = (end ?? content.length) - start;
60+
const migrated = migrateTemplate(template);
61+
62+
if (migrated !== null) {
63+
update.remove(start, length);
64+
update.insertLeft(start, migrated);
65+
}
66+
}
67+
68+
tree.commitUpdate(update);
69+
}
70+
}

0 commit comments

Comments
 (0)