diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index f62a492330..435d712ead 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -11,11 +11,11 @@ import * as path from 'path'; import * as ts from 'typescript'; import {Decorator, ReflectionHost} from '../../host'; -import {reflectObjectLiteral, staticallyResolve} from '../../metadata'; +import {filterToMembersWithDecorator, reflectObjectLiteral, staticallyResolve} from '../../metadata'; import {AnalysisOutput, CompileResult, DecoratorHandler} from '../../transform'; import {ResourceLoader} from './api'; -import {extractDirectiveMetadata} from './directive'; +import {extractDirectiveMetadata, extractQueriesFromDecorator, queriesFromFields} from './directive'; import {SelectorScopeRegistry} from './selector_scope'; import {isAngularCore, unwrapExpression} from './util'; @@ -59,9 +59,9 @@ export class ComponentDecoratorHandler implements DecoratorHandler { - const analysis = + const directiveResult = extractDirectiveMetadata(node, decorator, this.checker, this.reflector, this.isCore); + const analysis = directiveResult && directiveResult.metadata; // If the directive has a selector, it should be registered with the `SelectorScopeRegistry` so // when this directive appears in an `@NgModule` scope, its selector can be determined. @@ -58,7 +59,11 @@ export class DirectiveDecoratorHandler implements DecoratorHandler, + metadata: R3DirectiveMetadata, + decoratedElements: ClassMember[], +}|undefined { if (decorator.args === null || decorator.args.length !== 1) { throw new Error(`Incorrect number of arguments to @${decorator.name} decorator`); } @@ -93,6 +98,19 @@ export function extractDirectiveMetadata( const outputsFromMeta = parseFieldToPropertyMapping(directive, 'outputs', checker); const outputsFromFields = parseDecoratedFields( filterToMembersWithDecorator(decoratedElements, 'Output', coreModule), checker); + // Construct the list of queries. + const contentChildFromFields = queriesFromFields( + filterToMembersWithDecorator(decoratedElements, 'ContentChild', coreModule), checker); + const contentChildrenFromFields = queriesFromFields( + filterToMembersWithDecorator(decoratedElements, 'ContentChildren', coreModule), checker); + + const queries = [...contentChildFromFields, ...contentChildrenFromFields]; + + if (directive.has('queries')) { + const queriesFromDecorator = + extractQueriesFromDecorator(directive.get('queries') !, reflector, checker, isCore); + queries.push(...queriesFromDecorator.content); + } // Parse the selector. let selector = ''; @@ -112,7 +130,7 @@ export function extractDirectiveMetadata( // Detect if the component inherits from another class const usesInheritance = clazz.heritageClauses !== undefined && clazz.heritageClauses.some(hc => hc.token === ts.SyntaxKind.ExtendsKeyword); - return { + const metadata: R3DirectiveMetadata = { name: clazz.name !.text, deps: getConstructorDependencies(clazz, reflector, isCore), host: { @@ -124,17 +142,105 @@ export function extractDirectiveMetadata( usesOnChanges, }, inputs: {...inputsFromMeta, ...inputsFromFields}, - outputs: {...outputsFromMeta, ...outputsFromFields}, - queries: [], selector, + outputs: {...outputsFromMeta, ...outputsFromFields}, queries, selector, type: new WrappedNodeExpr(clazz.name !), typeSourceSpan: null !, usesInheritance, }; + return {decoratedElements, decorator: directive, metadata}; } -function assertIsStringArray(value: any[]): value is string[] { +export function extractQueryMetadata( + name: string, args: ReadonlyArray, propertyName: string, + checker: ts.TypeChecker): R3QueryMetadata { + if (args.length === 0) { + throw new Error(`@${name} must have arguments`); + } + const first = name === 'ViewChild' || name === 'ContentChild'; + const arg = staticallyResolve(args[0], checker); + + // Extract the predicate + let predicate: Expression|string[]|null = null; + if (arg instanceof Reference) { + predicate = new WrappedNodeExpr(args[0]); + } else if (typeof arg === 'string') { + predicate = [arg]; + } else if (isStringArrayOrDie(arg, '@' + name)) { + predicate = arg as string[]; + } else { + throw new Error(`@${name} predicate cannot be interpreted`); + } + + // Extract the read and descendants options. + let read: Expression|null = null; + // The default value for descendants is true for every decorator except @ContentChildren. + let descendants: boolean = name !== 'ContentChildren'; + if (args.length === 2) { + const optionsExpr = unwrapExpression(args[1]); + if (!ts.isObjectLiteralExpression(optionsExpr)) { + throw new Error(`@${name} options must be an object literal`); + } + const options = reflectObjectLiteral(optionsExpr); + if (options.has('read')) { + read = new WrappedNodeExpr(options.get('read') !); + } + + if (options.has('descendants')) { + const descendantsValue = staticallyResolve(options.get('descendants') !, checker); + if (typeof descendantsValue !== 'boolean') { + throw new Error(`@${name} options.descendants must be a boolean`); + } + descendants = descendantsValue; + } + } else if (args.length > 2) { + // Too many arguments. + throw new Error(`@${name} has too many arguments`); + } + + return { + propertyName, predicate, first, descendants, read, + }; +} + +export function extractQueriesFromDecorator( + queryData: ts.Expression, reflector: ReflectionHost, checker: ts.TypeChecker, + isCore: boolean): { + content: R3QueryMetadata[], + view: R3QueryMetadata[], +} { + const content: R3QueryMetadata[] = [], view: R3QueryMetadata[] = []; + const expr = unwrapExpression(queryData); + if (!ts.isObjectLiteralExpression(queryData)) { + throw new Error(`queries metadata must be an object literal`); + } + reflectObjectLiteral(queryData).forEach((queryExpr, propertyName) => { + queryExpr = unwrapExpression(queryExpr); + if (!ts.isNewExpression(queryExpr) || !ts.isIdentifier(queryExpr.expression)) { + throw new Error(`query metadata must be an instance of a query type`); + } + const type = reflector.getImportOfIdentifier(queryExpr.expression); + if (type === null || (!isCore && type.from !== '@angular/core') || + !QUERY_TYPES.has(type.name)) { + throw new Error(`query metadata must be an instance of a query type`); + } + + const query = extractQueryMetadata(type.name, queryExpr.arguments || [], propertyName, checker); + if (type.name.startsWith('Content')) { + content.push(query); + } else { + view.push(query); + } + }); + return {content, view}; +} + +function isStringArrayOrDie(value: any, name: string): value is string[] { + if (!Array.isArray(value)) { + return false; + } + for (let i = 0; i < value.length; i++) { if (typeof value[i] !== 'string') { - throw new Error(`Failed to resolve @Directive.inputs[${i}] to a string`); + throw new Error(`Failed to resolve ${name}[${i}] to a string`); } } return true; @@ -153,7 +259,7 @@ function parseFieldToPropertyMapping( // Resolve the field of interest from the directive metadata to a string[]. const metaValues = staticallyResolve(directive.get(field) !, checker); - if (!Array.isArray(metaValues) || !assertIsStringArray(metaValues)) { + if (!isStringArrayOrDie(metaValues, field)) { throw new Error(`Failed to resolve @Directive.${field}`); } @@ -199,3 +305,29 @@ function parseDecoratedFields( }, {} as{[field: string]: string}); } + +export function queriesFromFields( + fields: {member: ClassMember, decorators: Decorator[]}[], + checker: ts.TypeChecker): R3QueryMetadata[] { + return fields.map(({member, decorators}) => { + if (decorators.length !== 1) { + throw new Error(`Cannot have multiple query decorators on the same class member`); + } else if (!isPropertyTypeMember(member)) { + throw new Error(`Query decorator must go on a property-type member`); + } + const decorator = decorators[0]; + return extractQueryMetadata(decorator.name, decorator.args || [], member.name, checker); + }); +} + +function isPropertyTypeMember(member: ClassMember): boolean { + return member.kind === ClassMemberKind.Getter || member.kind === ClassMemberKind.Setter || + member.kind === ClassMemberKind.Property; +} + +const QUERY_TYPES = new Set([ + 'ContentChild', + 'ContentChildren', + 'ViewChild', + 'ViewChildren', +]); diff --git a/packages/compiler-cli/test/ngtsc/fake_core/index.ts b/packages/compiler-cli/test/ngtsc/fake_core/index.ts index 0735b5621a..de31c8b04c 100644 --- a/packages/compiler-cli/test/ngtsc/fake_core/index.ts +++ b/packages/compiler-cli/test/ngtsc/fake_core/index.ts @@ -6,7 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -type FnWithArg = (arg?: any) => T; +interface FnWithArg { + (...args: any[]): T; + new (...args: any[]): T; +} function callableClassDecorator(): FnWithArg<(clazz: any) => any> { return null !; @@ -16,6 +19,10 @@ function callableParamDecorator(): FnWithArg<(a: any, b: any, c: any) => void> { return null !; } +function callablePropDecorator(): FnWithArg<(a: any, b: any) => any> { + return null !; +} + export const Component = callableClassDecorator(); export const Directive = callableClassDecorator(); export const Injectable = callableClassDecorator(); @@ -27,6 +34,12 @@ export const Inject = callableParamDecorator(); export const Self = callableParamDecorator(); export const SkipSelf = callableParamDecorator(); export const Optional = callableParamDecorator(); + +export const ContentChild = callablePropDecorator(); +export const ContentChildren = callablePropDecorator(); +export const ViewChild = callablePropDecorator(); +export const ViewChildren = callablePropDecorator(); + export type ModuleWithProviders = any; export class ChangeDetectorRef {} diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index eb5e1623b0..82a019f025 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -412,4 +412,36 @@ describe('ngtsc behavioral tests', () => { .toContain( `factory: function FooCmp_Factory() { return new FooCmp(i0.ɵinjectAttribute("test"), i0.ɵinjectChangeDetectorRef(), i0.ɵinjectElementRef(), i0.ɵdirectiveInject(i0.INJECTOR), i0.ɵinjectTemplateRef(), i0.ɵinjectViewContainerRef()); }`); }); + + it('should generate queries for components', () => { + writeConfig(); + write(`test.ts`, ` + import {Component, ContentChild, ContentChildren, TemplateRef, ViewChild} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + queries: { + 'mview': new ViewChild('test1'), + 'mcontent': new ContentChild('test2'), + } + }) + class FooCmp { + @ContentChild('bar', {read: TemplateRef}) child: any; + @ContentChildren(TemplateRef) children: any; + get aview(): any { return null; } + @ViewChild('accessor') set aview(value: any) {} + } + `); + + const exitCode = main(['-p', basePath], errorSpy); + expect(errorSpy).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + const jsContents = getContents('test.js'); + expect(jsContents).toContain(`i0.ɵQ(null, ["bar"], true, TemplateRef)`); + expect(jsContents).toContain(`i0.ɵQ(null, TemplateRef, false)`); + expect(jsContents).toContain(`i0.ɵQ(null, ["test2"], true)`); + expect(jsContents).toContain(`i0.ɵQ(0, ["accessor"], true)`); + expect(jsContents).toContain(`i0.ɵQ(1, ["test1"], true)`); + }); });