Skip to content

Commit 3c53713

Browse files
devversionkara
authored andcommitted
refactor(core): static query schematic should handle asynchronous query usages properly (angular#29133)
With 6215799, we introduced a schematic for the Angular core package that automatically migrates unexplicit query definitions to the explicit query timing (static <-> dynamic). As the initial foundation was already big enough, it was planned to come up with a follow-up that handles asynchronous query usages properly. e.g. queries could be used in Promises, `setTimeout`, `setInterval`, `requestAnimationFrame` and more, but the schematic would incorrectly declare these queries as static. This commit ensures that we properly handle these micro/macro tasks and don't incorrectly consider queries as static. The declaration usage visitor should only check the synchronous control flow and completely ignore any statements within function like expressions which aren't explicitly executed in a synchronous way. e.g. IIFE's still work as the function expression is synchronously invoked. PR Close angular#29133
1 parent c09d0ed commit 3c53713

File tree

4 files changed

+268
-26
lines changed

4 files changed

+268
-26
lines changed

packages/core/schematics/migrations/static-queries/angular/analyze_query_usage.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,25 @@ function isQueryUsedStatically(
5353
// access it statically. e.g.
5454
// (1) queries used in the "ngOnInit" lifecycle hook are static.
5555
// (2) inputs with setters can access queries statically.
56-
const possibleStaticQueryNodes: ts.Node[] = classDecl.members.filter(m => {
57-
if (ts.isMethodDeclaration(m) && hasPropertyNameText(m.name) &&
58-
STATIC_QUERY_LIFECYCLE_HOOKS[query.type].indexOf(m.name.text) !== -1) {
59-
return true;
60-
} else if (
61-
knownInputNames && ts.isSetAccessor(m) && hasPropertyNameText(m.name) &&
62-
knownInputNames.indexOf(m.name.text) !== -1) {
63-
return true;
64-
}
65-
return false;
66-
});
56+
const possibleStaticQueryNodes: ts.Node[] =
57+
classDecl.members
58+
.filter(m => {
59+
if (ts.isMethodDeclaration(m) && m.body && hasPropertyNameText(m.name) &&
60+
STATIC_QUERY_LIFECYCLE_HOOKS[query.type].indexOf(m.name.text) !== -1) {
61+
return true;
62+
} else if (
63+
knownInputNames && ts.isSetAccessor(m) && m.body && hasPropertyNameText(m.name) &&
64+
knownInputNames.indexOf(m.name.text) !== -1) {
65+
return true;
66+
}
67+
return false;
68+
})
69+
.map((member: ts.SetAccessorDeclaration | ts.MethodDeclaration) => member.body !);
6770

6871
// In case nodes that can possibly access a query statically have been found, check
69-
// if the query declaration is used within any of these nodes.
72+
// if the query declaration is synchronously used within any of these nodes.
7073
if (possibleStaticQueryNodes.length &&
71-
possibleStaticQueryNodes.some(hookNode => usageVisitor.isUsedInNode(hookNode))) {
74+
possibleStaticQueryNodes.some(n => usageVisitor.isSynchronouslyUsedInNode(n))) {
7275
return true;
7376
}
7477

packages/core/schematics/migrations/static-queries/angular/declaration_usage_visitor.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as ts from 'typescript';
10+
import {isFunctionLikeDeclaration, unwrapExpression} from '../typescript/functions';
1011

1112
/**
1213
* Class that can be used to determine if a given TypeScript node is used within
@@ -26,19 +27,33 @@ export class DeclarationUsageVisitor {
2627
}
2728

2829
private addJumpExpressionToQueue(node: ts.Expression, nodeQueue: ts.Node[]) {
30+
// In case the given expression is already referring to a function-like declaration,
31+
// we don't need to resolve the symbol of the expression as the jump expression is
32+
// defined inline and we can just add the given node to the queue.
33+
if (isFunctionLikeDeclaration(node) && node.body) {
34+
nodeQueue.push(node.body);
35+
return;
36+
}
37+
2938
const callExprSymbol = this.typeChecker.getSymbolAtLocation(node);
3039

31-
// Note that we should not add previously visited symbols to the queue as this
32-
// could cause cycles.
33-
if (callExprSymbol && callExprSymbol.valueDeclaration &&
34-
!this.visitedJumpExprSymbols.has(callExprSymbol)) {
40+
if (!callExprSymbol || !callExprSymbol.valueDeclaration ||
41+
!isFunctionLikeDeclaration(callExprSymbol.valueDeclaration)) {
42+
return;
43+
}
44+
45+
const expressionDecl = callExprSymbol.valueDeclaration;
46+
47+
// Note that we should not add previously visited symbols to the queue as
48+
// this could cause cycles.
49+
if (expressionDecl.body && !this.visitedJumpExprSymbols.has(callExprSymbol)) {
3550
this.visitedJumpExprSymbols.add(callExprSymbol);
36-
nodeQueue.push(callExprSymbol.valueDeclaration);
51+
nodeQueue.push(expressionDecl.body);
3752
}
3853
}
3954

4055
private addNewExpressionToQueue(node: ts.NewExpression, nodeQueue: ts.Node[]) {
41-
const newExprSymbol = this.typeChecker.getSymbolAtLocation(node.expression);
56+
const newExprSymbol = this.typeChecker.getSymbolAtLocation(unwrapExpression(node.expression));
4257

4358
// Only handle new expressions which resolve to classes. Technically "new" could
4459
// also call void functions or objects with a constructor signature. Also note that
@@ -50,15 +65,15 @@ export class DeclarationUsageVisitor {
5065
}
5166

5267
const targetConstructor =
53-
newExprSymbol.valueDeclaration.members.find(d => ts.isConstructorDeclaration(d));
68+
newExprSymbol.valueDeclaration.members.find(ts.isConstructorDeclaration);
5469

55-
if (targetConstructor) {
70+
if (targetConstructor && targetConstructor.body) {
5671
this.visitedJumpExprSymbols.add(newExprSymbol);
57-
nodeQueue.push(targetConstructor);
72+
nodeQueue.push(targetConstructor.body);
5873
}
5974
}
6075

61-
isUsedInNode(searchNode: ts.Node): boolean {
76+
isSynchronouslyUsedInNode(searchNode: ts.Node): boolean {
6277
const nodeQueue: ts.Node[] = [searchNode];
6378
this.visitedJumpExprSymbols.clear();
6479

@@ -72,7 +87,7 @@ export class DeclarationUsageVisitor {
7287
// Handle call expressions within TypeScript nodes that cause a jump in control
7388
// flow. We resolve the call expression value declaration and add it to the node queue.
7489
if (ts.isCallExpression(node)) {
75-
this.addJumpExpressionToQueue(node.expression, nodeQueue);
90+
this.addJumpExpressionToQueue(unwrapExpression(node.expression), nodeQueue);
7691
}
7792

7893
// Handle new expressions that cause a jump in control flow. We resolve the
@@ -81,7 +96,12 @@ export class DeclarationUsageVisitor {
8196
this.addNewExpressionToQueue(node, nodeQueue);
8297
}
8398

84-
nodeQueue.push(...node.getChildren());
99+
// Do not visit nodes that declare a block of statements but are not executed
100+
// synchronously (e.g. function declarations). We only want to check TypeScript
101+
// nodes which are synchronously executed in the control flow.
102+
if (!isFunctionLikeDeclaration(node)) {
103+
nodeQueue.push(...node.getChildren());
104+
}
85105
}
86106
return false;
87107
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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 * as ts from 'typescript';
10+
11+
/** Checks whether a given node is a function like declaration. */
12+
export function isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration {
13+
return ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) ||
14+
ts.isArrowFunction(node) || ts.isFunctionExpression(node) ||
15+
ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
16+
}
17+
18+
/**
19+
* Unwraps a given expression TypeScript node. Expressions can be wrapped within multiple
20+
* parentheses. e.g. "(((({exp}))))()". The function should return the TypeScript node
21+
* referring to the inner expression. e.g "exp".
22+
*/
23+
export function unwrapExpression(node: ts.Expression | ts.ParenthesizedExpression): ts.Expression {
24+
if (ts.isParenthesizedExpression(node)) {
25+
return unwrapExpression(node.expression);
26+
} else {
27+
return node;
28+
}
29+
}

packages/core/schematics/test/static_queries_migration_spec.ts

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,16 @@ describe('static-queries migration', () => {
287287
288288
ngOnInit() {
289289
new A(this);
290+
291+
new class Inline {
292+
constructor(private ctx: MyComp) {
293+
this.a();
294+
}
295+
296+
a() {
297+
this.ctx.query2.useStatically();
298+
}
299+
}(this);
290300
}
291301
}
292302
@@ -302,7 +312,33 @@ describe('static-queries migration', () => {
302312
expect(tree.readContent('/index.ts'))
303313
.toContain(`@${queryType}('test', { static: true }) query: any;`);
304314
expect(tree.readContent('/index.ts'))
305-
.toContain(`@${queryType}('test', { static: false }) query2: any;`);
315+
.toContain(`@${queryType}('test', { static: true }) query2: any;`);
316+
});
317+
318+
it('should detect queries used in parenthesized new expressions', () => {
319+
writeFile('/index.ts', `
320+
import {Component, ${queryType}} from '@angular/core';
321+
322+
@Component({template: '<span #test></span>'})
323+
export class MyComp {
324+
@${queryType}('test') query: any;
325+
326+
ngOnInit() {
327+
new ((A))(this);
328+
}
329+
}
330+
331+
export class A {
332+
constructor(ctx: MyComp) {
333+
ctx.query.test();
334+
}
335+
}
336+
`);
337+
338+
runMigration();
339+
340+
expect(tree.readContent('/index.ts'))
341+
.toContain(`@${queryType}('test', { static: true }) query: any;`);
306342
});
307343

308344
it('should detect queries in lifecycle hook with string literal name', () => {
@@ -520,5 +556,159 @@ describe('static-queries migration', () => {
520556
expect(tree.readContent('/src/index.ts'))
521557
.toContain(`@${queryType}('test', { static: true }) query: any;`);
522558
});
559+
560+
it('should not mark queries used in promises as static', () => {
561+
writeFile('/index.ts', `
562+
import {Component, ${queryType}} from '@angular/core';
563+
564+
@Component({template: '<span #test></span>'})
565+
export class MyComp {
566+
private @${queryType}('test') query: any;
567+
private @${queryType}('test') query2: any;
568+
569+
ngOnInit() {
570+
const a = Promise.resolve();
571+
572+
Promise.resolve().then(() => {
573+
this.query.doSomething();
574+
});
575+
576+
Promise.reject().catch(() => {
577+
this.query.doSomething();
578+
});
579+
580+
a.then(() => {}).then(() => {
581+
this.query.doSomething();
582+
});
583+
584+
Promise.resolve().then(this.createPromiseCb());
585+
}
586+
587+
createPromiseCb() {
588+
this.query2.doSomething();
589+
return () => { /* empty callback */}
590+
}
591+
}
592+
`);
593+
594+
runMigration();
595+
596+
expect(tree.readContent('/index.ts'))
597+
.toContain(`@${queryType}('test', { static: false }) query: any;`);
598+
expect(tree.readContent('/index.ts'))
599+
.toContain(`@${queryType}('test', { static: true }) query2: any;`);
600+
});
601+
602+
it('should not mark queries used in setTimeout as static', () => {
603+
writeFile('/index.ts', `
604+
import {Component, ${queryType}} from '@angular/core';
605+
606+
@Component({template: '<span #test></span>'})
607+
export class MyComp {
608+
private @${queryType}('test') query: any;
609+
private @${queryType}('test') query2: any;
610+
private @${queryType}('test') query3: any;
611+
612+
ngOnInit() {
613+
setTimeout(function() {
614+
this.query.doSomething();
615+
});
616+
617+
setTimeout(createCallback(this));
618+
}
619+
}
620+
621+
function createCallback(instance: MyComp) {
622+
instance.query2.doSomething();
623+
return () => instance.query3.doSomething();
624+
}
625+
`);
626+
627+
runMigration();
628+
629+
expect(tree.readContent('/index.ts'))
630+
.toContain(`@${queryType}('test', { static: false }) query: any;`);
631+
expect(tree.readContent('/index.ts'))
632+
.toContain(`@${queryType}('test', { static: true }) query2: any;`);
633+
expect(tree.readContent('/index.ts'))
634+
.toContain(`@${queryType}('test', { static: false }) query3: any;`);
635+
});
636+
637+
it('should not mark queries used in "addEventListener" as static', () => {
638+
writeFile('/index.ts', `
639+
import {Component, ElementRef, ${queryType}} from '@angular/core';
640+
641+
@Component({template: '<span #test></span>'})
642+
export class MyComp {
643+
private @${queryType}('test') query: any;
644+
645+
constructor(private elementRef: ElementRef) {}
646+
647+
ngOnInit() {
648+
this.elementRef.addEventListener(() => {
649+
this.query.classList.add('test');
650+
});
651+
}
652+
}
653+
`);
654+
655+
runMigration();
656+
657+
expect(tree.readContent('/index.ts'))
658+
.toContain(`@${queryType}('test', { static: false }) query: any;`);
659+
});
660+
661+
it('should not mark queries used in "requestAnimationFrame" as static', () => {
662+
writeFile('/index.ts', `
663+
import {Component, ElementRef, ${queryType}} from '@angular/core';
664+
665+
@Component({template: '<span #test></span>'})
666+
export class MyComp {
667+
private @${queryType}('test') query: any;
668+
669+
constructor(private elementRef: ElementRef) {}
670+
671+
ngOnInit() {
672+
requestAnimationFrame(() => {
673+
this.query.classList.add('test');
674+
});
675+
}
676+
}
677+
`);
678+
679+
runMigration();
680+
681+
expect(tree.readContent('/index.ts'))
682+
.toContain(`@${queryType}('test', { static: false }) query: any;`);
683+
});
684+
685+
it('should mark queries used in immediately-invoked function expression as static', () => {
686+
writeFile('/index.ts', `
687+
import {Component, ${queryType}} from '@angular/core';
688+
689+
@Component({template: '<span #test></span>'})
690+
export class MyComp {
691+
private @${queryType}('test') query: any;
692+
private @${queryType}('test') query2: any;
693+
694+
ngOnInit() {
695+
(() => {
696+
this.query.usedStatically();
697+
})();
698+
699+
(function(ctx) {
700+
ctx.query2.useStatically();
701+
})(this);
702+
}
703+
}
704+
`);
705+
706+
runMigration();
707+
708+
expect(tree.readContent('/index.ts'))
709+
.toContain(`@${queryType}('test', { static: true }) query: any;`);
710+
expect(tree.readContent('/index.ts'))
711+
.toContain(`@${queryType}('test', { static: true }) query2: any;`);
712+
});
523713
}
524714
});

0 commit comments

Comments
 (0)