Skip to content

Commit

Permalink
feat(core): update schematic to migrate to explicit query timing (#28983
Browse files Browse the repository at this point in the history
)

Introduces an update schematic for the "@angular/core" package
that automatically migrates pre-V8 "ViewChild" and "ContentChild"
queries to the new explicit timing syntax. This is not required
yet, but with Ivy, queries will be "dynamic" by default. Therefore
specifying an explicit query timing ensures that developers can
smoothly migrate to Ivy (once it's the default).

Read more about the explicit timing API here:
#28810

PR Close #28983
  • Loading branch information
devversion authored and AndrewKushnir committed Mar 5, 2019
1 parent ff95505 commit 6215799
Show file tree
Hide file tree
Showing 24 changed files with 1,456 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/core/BUILD.bazel
Expand Up @@ -29,6 +29,9 @@ ng_package(
"//packages/core/testing:package.json",
],
entry_point = "packages/core/index.js",
packages = [
"//packages/core/schematics:npm_package",
],
tags = [
"release-with-framework",
],
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Expand Up @@ -24,6 +24,7 @@
"url": "https://github.com/angular/angular.git"
},
"ng-update": {
"migrations":"./schematics/migrations.json",
"packageGroup": "NG_UPDATE_PACKAGE_GROUP"
},
"sideEffects": false
Expand Down
13 changes: 13 additions & 0 deletions packages/core/schematics/BUILD.bazel
@@ -0,0 +1,13 @@
load("//tools:defaults.bzl", "npm_package")

exports_files([
"tsconfig.json",
"migrations.json",
])

npm_package(
name = "npm_package",
srcs = ["migrations.json"],
visibility = ["//packages/core:__pkg__"],
deps = ["//packages/core/schematics/migrations/static-queries"],
)
9 changes: 9 additions & 0 deletions packages/core/schematics/migrations.json
@@ -0,0 +1,9 @@
{
"schematics": {
"migration-v8-static-queries": {
"version": "8",
"description": "Migrates ViewChild and ContentChild to explicit query timing",
"factory": "./migrations/static-queries/index"
}
}
}
20 changes: 20 additions & 0 deletions packages/core/schematics/migrations/static-queries/BUILD.bazel
@@ -0,0 +1,20 @@
load("//tools:defaults.bzl", "ts_library")

ts_library(
name = "static-queries",
srcs = glob(
["**/*.ts"],
exclude = ["index_spec.ts"],
),
tsconfig = "//packages/core/schematics:tsconfig.json",
visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
deps = [
"//packages/core/schematics/utils",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
"@npm//typescript",
],
)
@@ -0,0 +1,84 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import * as ts from 'typescript';

import {hasPropertyNameText} from '../typescript/property_name';
import {DeclarationUsageVisitor} from './declaration_usage_visitor';
import {ClassMetadataMap} from './ng_query_visitor';
import {NgQueryDefinition, QueryTiming, QueryType} from './query-definition';


/**
* Object that maps a given type of query to a list of lifecycle hooks that
* could be used to access such a query statically.
*/
const STATIC_QUERY_LIFECYCLE_HOOKS = {
[QueryType.ViewChild]: ['ngOnInit', 'ngAfterContentInit', 'ngAfterContentChecked'],
[QueryType.ContentChild]: ['ngOnInit'],
};

/**
* Analyzes the usage of the given query and determines the query timing based
* on the current usage of the query.
*/
export function analyzeNgQueryUsage(
query: NgQueryDefinition, classMetadata: ClassMetadataMap,
typeChecker: ts.TypeChecker): QueryTiming {
return isQueryUsedStatically(query.container, query, classMetadata, typeChecker, []) ?
QueryTiming.STATIC :
QueryTiming.DYNAMIC;
}

/** Checks whether a given class or it's derived classes use the specified query statically. */
function isQueryUsedStatically(
classDecl: ts.ClassDeclaration, query: NgQueryDefinition, classMetadataMap: ClassMetadataMap,
typeChecker: ts.TypeChecker, knownInputNames: string[]): boolean {
const usageVisitor = new DeclarationUsageVisitor(query.property, typeChecker);
const classMetadata = classMetadataMap.get(classDecl);

// In case there is metadata for the current class, we collect all resolved Angular input
// names and add them to the list of known inputs that need to be checked for usages of
// the current query. e.g. queries used in an @Input() *setter* are always static.
if (classMetadata) {
knownInputNames.push(...classMetadata.ngInputNames);
}

// List of TypeScript nodes which can contain usages of the given query in order to
// access it statically. e.g.
// (1) queries used in the "ngOnInit" lifecycle hook are static.
// (2) inputs with setters can access queries statically.
const possibleStaticQueryNodes: ts.Node[] = classDecl.members.filter(m => {
if (ts.isMethodDeclaration(m) && hasPropertyNameText(m.name) &&
STATIC_QUERY_LIFECYCLE_HOOKS[query.type].indexOf(m.name.text) !== -1) {
return true;
} else if (
knownInputNames && ts.isSetAccessor(m) && hasPropertyNameText(m.name) &&
knownInputNames.indexOf(m.name.text) !== -1) {
return true;
}
return false;
});

// In case nodes that can possibly access a query statically have been found, check
// if the query declaration is used within any of these nodes.
if (possibleStaticQueryNodes.length &&
possibleStaticQueryNodes.some(hookNode => usageVisitor.isUsedInNode(hookNode))) {
return true;
}

// In case there are classes that derive from the current class, visit each
// derived class as inherited queries could be used statically.
if (classMetadata) {
return classMetadata.derivedClasses.some(
derivedClass => isQueryUsedStatically(
derivedClass, query, classMetadataMap, typeChecker, knownInputNames));
}

return false;
}
@@ -0,0 +1,88 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import * as ts from 'typescript';

/**
* Class that can be used to determine if a given TypeScript node is used within
* other given TypeScript nodes. This is achieved by walking through all children
* of the given node and checking for usages of the given declaration. The visitor
* also handles potential control flow changes caused by call/new expressions.
*/
export class DeclarationUsageVisitor {
/** Set of visited symbols that caused a jump in control flow. */
private visitedJumpExprSymbols = new Set<ts.Symbol>();

constructor(private declaration: ts.Node, private typeChecker: ts.TypeChecker) {}

private isReferringToSymbol(node: ts.Node): boolean {
const symbol = this.typeChecker.getSymbolAtLocation(node);
return !!symbol && symbol.valueDeclaration === this.declaration;
}

private addJumpExpressionToQueue(node: ts.Expression, nodeQueue: ts.Node[]) {
const callExprSymbol = this.typeChecker.getSymbolAtLocation(node);

// Note that we should not add previously visited symbols to the queue as this
// could cause cycles.
if (callExprSymbol && callExprSymbol.valueDeclaration &&
!this.visitedJumpExprSymbols.has(callExprSymbol)) {
this.visitedJumpExprSymbols.add(callExprSymbol);
nodeQueue.push(callExprSymbol.valueDeclaration);
}
}

private addNewExpressionToQueue(node: ts.NewExpression, nodeQueue: ts.Node[]) {
const newExprSymbol = this.typeChecker.getSymbolAtLocation(node.expression);

// Only handle new expressions which resolve to classes. Technically "new" could
// also call void functions or objects with a constructor signature. Also note that
// we should not visit already visited symbols as this could cause cycles.
if (!newExprSymbol || !newExprSymbol.valueDeclaration ||
!ts.isClassDeclaration(newExprSymbol.valueDeclaration) ||
this.visitedJumpExprSymbols.has(newExprSymbol)) {
return;
}

const targetConstructor =
newExprSymbol.valueDeclaration.members.find(d => ts.isConstructorDeclaration(d));

if (targetConstructor) {
this.visitedJumpExprSymbols.add(newExprSymbol);
nodeQueue.push(targetConstructor);
}
}

isUsedInNode(searchNode: ts.Node): boolean {
const nodeQueue: ts.Node[] = [searchNode];
this.visitedJumpExprSymbols.clear();

while (nodeQueue.length) {
const node = nodeQueue.shift() !;

if (ts.isIdentifier(node) && this.isReferringToSymbol(node)) {
return true;
}

// Handle call expressions within TypeScript nodes that cause a jump in control
// flow. We resolve the call expression value declaration and add it to the node queue.
if (ts.isCallExpression(node)) {
this.addJumpExpressionToQueue(node.expression, nodeQueue);
}

// Handle new expressions that cause a jump in control flow. We resolve the
// constructor declaration of the target class and add it to the node queue.
if (ts.isNewExpression(node)) {
this.addNewExpressionToQueue(node, nodeQueue);
}

nodeQueue.push(...node.getChildren());
}
return false;
}
}
@@ -0,0 +1,26 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import * as ts from 'typescript';
import {getCallDecoratorImport} from '../typescript/decorators';

export interface NgDecorator {
name: string;
node: ts.Decorator;
}

/**
* Gets all decorators which are imported from an Angular package (e.g. "@angular/core")
* from a list of decorators.
*/
export function getAngularDecorators(
typeChecker: ts.TypeChecker, decorators: ReadonlyArray<ts.Decorator>): NgDecorator[] {
return decorators.map(node => ({node, importData: getCallDecoratorImport(typeChecker, node)}))
.filter(({importData}) => importData && importData.importModule.startsWith('@angular/'))
.map(({node, importData}) => ({node, name: importData !.name}));
}
@@ -0,0 +1,88 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import * as ts from 'typescript';

import {getPropertyNameText, hasPropertyNameText} from '../typescript/property_name';
import {getAngularDecorators} from './decorators';

/** Analyzes the given class and resolves the name of all inputs which are declared. */
export function getInputNamesOfClass(
node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[] {
const resolvedInputSetters: string[] = [];

// Determines the names of all inputs defined in the current class declaration by
// checking whether a given property/getter/setter has the "@Input" decorator applied.
node.members.forEach(m => {
if (!m.decorators || !m.decorators.length ||
!ts.isPropertyDeclaration(m) && !ts.isSetAccessor(m) && !ts.isGetAccessor(m)) {
return;
}

const inputDecorator =
getAngularDecorators(typeChecker, m.decorators !).find(d => d.name === 'Input');

if (inputDecorator && hasPropertyNameText(m.name)) {
resolvedInputSetters.push(m.name.text);
}
});

// Besides looking for immediate setters in the current class declaration, developers
// can also define inputs in the directive metadata using the "inputs" property. We
// also need to determine these inputs which are declared in the directive metadata.
const metadataInputs = getInputNamesFromMetadata(node, typeChecker);

if (metadataInputs) {
resolvedInputSetters.push(...metadataInputs);
}

return resolvedInputSetters;
}

/**
* Determines the names of all inputs declared in the directive/component metadata
* of the given class.
*/
function getInputNamesFromMetadata(
node: ts.ClassDeclaration, typeChecker: ts.TypeChecker): string[]|null {
if (!node.decorators || !node.decorators.length) {
return null;
}

const decorator = getAngularDecorators(typeChecker, node.decorators)
.find(d => d.name === 'Directive' || d.name === 'Component');

// In case no directive/component decorator could be found for this class, just
// return null as there is no metadata where an input could be declared.
if (!decorator) {
return null;
}

const decoratorCall = decorator.node.expression as ts.CallExpression;

// In case the decorator does define any metadata, there is no metadata
// where inputs could be declared. This is an edge case because there
// always needs to be an object literal, but in case there isn't we just
// want to skip the invalid decorator and return null.
if (!ts.isObjectLiteralExpression(decoratorCall.arguments[0])) {
return null;
}

const metadata = decoratorCall.arguments[0] as ts.ObjectLiteralExpression;
const inputs = metadata.properties.filter(ts.isPropertyAssignment)
.find(p => getPropertyNameText(p.name) === 'inputs');

// In case there is no "inputs" property in the directive metadata,
// just return "null" as no inputs can be declared for this class.
if (!inputs || !ts.isArrayLiteralExpression(inputs.initializer)) {
return null;
}

return inputs.initializer.elements.filter(ts.isStringLiteralLike)
.map(element => element.text.split(':')[0].trim());
}

0 comments on commit 6215799

Please sign in to comment.