diff --git a/goldens/public-api/compiler-cli/error_code.md b/goldens/public-api/compiler-cli/error_code.md index 2488a2150c3..319cc5d3384 100644 --- a/goldens/public-api/compiler-cli/error_code.md +++ b/goldens/public-api/compiler-cli/error_code.md @@ -23,6 +23,7 @@ export enum ErrorCode { CONFIG_FLAT_MODULE_NO_INDEX = 4001, // (undocumented) CONFIG_STRICT_TEMPLATES_IMPLIES_FULL_TEMPLATE_TYPECHECK = 4002, + CONFLICTING_INPUT_TRANSFORM = 2020, // (undocumented) DECORATOR_ARG_NOT_LITERAL = 1001, // (undocumented) diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts index bcf5a851edf..df1f01ad1b4 100644 --- a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_directive_linker_1.ts @@ -5,7 +5,7 @@ * 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 {compileDirectiveFromMetadata, ConstantPool, ForwardRefHandling, makeBindingParser, outputAst as o, ParseLocation, ParseSourceFile, ParseSourceSpan, R3DeclareDirectiveMetadata, R3DeclareHostDirectiveMetadata, R3DeclareQueryMetadata, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3PartialDeclaration, R3QueryMetadata} from '@angular/compiler'; +import {compileDirectiveFromMetadata, ConstantPool, ForwardRefHandling, makeBindingParser, outputAst as o, ParseLocation, ParseSourceFile, ParseSourceSpan, R3DeclareDirectiveMetadata, R3DeclareHostDirectiveMetadata, R3DeclareQueryMetadata, R3DirectiveMetadata, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3PartialDeclaration, R3QueryMetadata} from '@angular/compiler'; import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system'; import {Range} from '../../ast/ast_host'; @@ -81,19 +81,29 @@ export function toR3DirectiveMeta( * Decodes the AST value for a single input to its representation as used in the metadata. */ function toInputMapping( - value: AstValue, - key: string): {bindingPropertyName: string, classPropertyName: string, required: boolean} { + value: AstValue, key: string): R3InputMetadata { if (value.isString()) { - return {bindingPropertyName: value.getString(), classPropertyName: key, required: false}; + return { + bindingPropertyName: value.getString(), + classPropertyName: key, + required: false, + transformFunction: null, + }; } - const values = value.getArray().map(innerValue => innerValue.getString()); - if (values.length !== 2) { + const values = value.getArray(); + if (values.length !== 2 && values.length !== 3) { throw new FatalLinkerError( value.expression, - 'Unsupported input, expected a string or an array containing exactly two strings'); + 'Unsupported input, expected a string or an array containing two strings and an optional function'); } - return {bindingPropertyName: values[0], classPropertyName: values[1], required: false}; + + return { + bindingPropertyName: values[0].getString(), + classPropertyName: values[1].getString(), + transformFunction: values.length > 2 ? values[2].getOpaque() : null, + required: false, + }; } /** diff --git a/packages/compiler-cli/src/ngtsc/annotations/common/index.ts b/packages/compiler-cli/src/ngtsc/annotations/common/index.ts index 31c9461ece6..e252c7a9420 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/common/index.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/common/index.ts @@ -16,3 +16,4 @@ export * from './src/metadata'; export * from './src/references_registry'; export * from './src/schema'; export * from './src/util'; +export * from './src/input_transforms'; diff --git a/packages/compiler-cli/src/ngtsc/annotations/common/src/input_transforms.ts b/packages/compiler-cli/src/ngtsc/annotations/common/src/input_transforms.ts new file mode 100644 index 00000000000..f1bfd81e284 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/common/src/input_transforms.ts @@ -0,0 +1,31 @@ +/*! + * @license + * Copyright Google LLC 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 {outputAst} from '@angular/compiler'; + +import {ClassPropertyMapping, InputMapping} from '../../../metadata'; +import {CompileResult} from '../../../transform'; + +/** Generates additional fields to be added to a class that has inputs with transform functions. */ +export function compileInputTransformFields(inputs: ClassPropertyMapping): + CompileResult[] { + const extraFields: CompileResult[] = []; + + for (const input of inputs) { + if (input.transform) { + extraFields.push({ + name: `ngAcceptInputType_${input.classPropertyName}`, + type: outputAst.transplantedType(input.transform.type), + statements: [], + initializer: null + }); + } + } + + return extraFields; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts b/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts index 9e784f5dff2..484ff277159 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/common/src/util.ts @@ -330,20 +330,28 @@ export function createSourceSpan(node: ts.Node): ParseSourceSpan { * Collate the factory and definition compiled results into an array of CompileResult objects. */ export function compileResults( - fac: CompileResult, def: R3CompiledExpression, metadataStmt: Statement|null, - propName: string): CompileResult[] { + fac: CompileResult, def: R3CompiledExpression, metadataStmt: Statement|null, propName: string, + additionalFields: CompileResult[]|null): CompileResult[] { const statements = def.statements; if (metadataStmt !== null) { statements.push(metadataStmt); } - return [ - fac, { + + const results = [ + fac, + { name: propName, initializer: def.expression, statements: def.statements, type: def.type, - } + }, ]; + + if (additionalFields !== null) { + results.push(...additionalFields); + } + + return results; } export function toFactoryMetadata( diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 764e9efc64f..45ecb98fae8 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -26,7 +26,7 @@ import {TypeCheckableDirectiveMeta, TypeCheckContext} from '../../../typecheck/a import {ExtendedTemplateChecker} from '../../../typecheck/extended/api'; import {getSourceFile} from '../../../util/src/typescript'; import {Xi18nContext} from '../../../xi18n'; -import {combineResolvers, compileDeclareFactory, compileNgFactoryDefField, compileResults, extractClassMetadata, extractSchemas, findAngularDecorator, forwardRefResolver, getDirectiveDiagnostics, getProviderDiagnostics, InjectableClassRegistry, isExpressionForwardReference, readBaseClass, ReferencesRegistry, resolveEnumValue, resolveImportedFile, resolveLiteral, resolveProvidersRequiringFactory, ResourceLoader, toFactoryMetadata, validateHostDirectives, wrapFunctionExpressionsInParens,} from '../../common'; +import {combineResolvers, compileDeclareFactory, compileInputTransformFields, compileNgFactoryDefField, compileResults, extractClassMetadata, extractSchemas, findAngularDecorator, forwardRefResolver, getDirectiveDiagnostics, getProviderDiagnostics, InjectableClassRegistry, isExpressionForwardReference, readBaseClass, ReferencesRegistry, resolveEnumValue, resolveImportedFile, resolveLiteral, resolveProvidersRequiringFactory, ResourceLoader, toFactoryMetadata, validateHostDirectives, wrapFunctionExpressionsInParens,} from '../../common'; import {extractDirectiveMetadata, parseFieldStringArrayValue} from '../../directive'; import {createModuleWithProvidersResolver, NgModuleSymbol} from '../../ng_module'; @@ -940,10 +940,11 @@ export class ComponentDecoratorHandler implements const meta: R3ComponentMetadata = {...analysis.meta, ...resolution}; const fac = compileNgFactoryDefField(toFactoryMetadata(meta, FactoryTarget.Component)); const def = compileComponentFromMetadata(meta, pool, makeBindingParser()); + const inputTransformFields = compileInputTransformFields(analysis.inputs); const classMetadata = analysis.classMetadata !== null ? compileClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵcmp'); + return compileResults(fac, def, classMetadata, 'ɵcmp', inputTransformFields); } compilePartial( @@ -963,11 +964,12 @@ export class ComponentDecoratorHandler implements const meta: R3ComponentMetadata = {...analysis.meta, ...resolution}; const fac = compileDeclareFactory(toFactoryMetadata(meta, FactoryTarget.Component)); + const inputTransformFields = compileInputTransformFields(analysis.inputs); const def = compileDeclareComponentFromMetadata(meta, analysis.template, templateInfo); const classMetadata = analysis.classMetadata !== null ? compileDeclareClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵcmp'); + return compileResults(fac, def, classMetadata, 'ɵcmp', inputTransformFields); } /** diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/BUILD.bazel b/packages/compiler-cli/src/ngtsc/annotations/directive/BUILD.bazel index 746000e8a31..710a1115985 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/BUILD.bazel @@ -19,6 +19,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/reflection", "//packages/compiler-cli/src/ngtsc/scope", "//packages/compiler-cli/src/ngtsc/transform", + "//packages/compiler-cli/src/ngtsc/translator", "@npm//@types/node", "@npm//typescript", ], diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts index 2a60837a872..e64585cfca9 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/handler.ts @@ -17,7 +17,7 @@ import {PerfEvent, PerfRecorder} from '../../../perf'; import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, ReflectionHost} from '../../../reflection'; import {LocalModuleScopeRegistry} from '../../../scope'; import {AnalysisOutput, CompileResult, DecoratorHandler, DetectResult, HandlerFlags, HandlerPrecedence, ResolveResult} from '../../../transform'; -import {compileDeclareFactory, compileNgFactoryDefField, compileResults, extractClassMetadata, findAngularDecorator, getDirectiveDiagnostics, getProviderDiagnostics, getUndecoratedClassWithAngularFeaturesDiagnostic, InjectableClassRegistry, isAngularDecorator, readBaseClass, ReferencesRegistry, resolveProvidersRequiringFactory, toFactoryMetadata, validateHostDirectives} from '../../common'; +import {compileDeclareFactory, compileInputTransformFields, compileNgFactoryDefField, compileResults, extractClassMetadata, findAngularDecorator, getDirectiveDiagnostics, getProviderDiagnostics, getUndecoratedClassWithAngularFeaturesDiagnostic, InjectableClassRegistry, isAngularDecorator, readBaseClass, ReferencesRegistry, resolveProvidersRequiringFactory, toFactoryMetadata, validateHostDirectives} from '../../common'; import {extractDirectiveMetadata} from './shared'; import {DirectiveSymbol} from './symbol'; @@ -207,10 +207,11 @@ export class DirectiveDecoratorHandler implements resolution: Readonly, pool: ConstantPool): CompileResult[] { const fac = compileNgFactoryDefField(toFactoryMetadata(analysis.meta, FactoryTarget.Directive)); const def = compileDirectiveFromMetadata(analysis.meta, pool, makeBindingParser()); + const inputTransformFields = compileInputTransformFields(analysis.inputs); const classMetadata = analysis.classMetadata !== null ? compileClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵdir'); + return compileResults(fac, def, classMetadata, 'ɵdir', inputTransformFields); } compilePartial( @@ -218,10 +219,12 @@ export class DirectiveDecoratorHandler implements resolution: Readonly): CompileResult[] { const fac = compileDeclareFactory(toFactoryMetadata(analysis.meta, FactoryTarget.Directive)); const def = compileDeclareDirectiveFromMetadata(analysis.meta); + const inputTransformFields = compileInputTransformFields(analysis.inputs); const classMetadata = analysis.classMetadata !== null ? compileDeclareClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵdir'); + + return compileResults(fac, def, classMetadata, 'ɵdir', inputTransformFields); } /** diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts index e850ec18724..5d38187c49f 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {createMayBeForwardRefExpression, emitDistinctChangesOnlyDefaultValue, Expression, ExternalExpr, ForwardRefHandling, getSafePropertyAccessString, MaybeForwardRefExpression, ParsedHostBindings, ParseError, parseHostBindings, R3DirectiveMetadata, R3HostDirectiveMetadata, R3QueryMetadata, verifyHostBindings, WrappedNodeExpr,} from '@angular/compiler'; +import {createMayBeForwardRefExpression, emitDistinctChangesOnlyDefaultValue, Expression, ExternalExpr, ForwardRefHandling, getSafePropertyAccessString, MaybeForwardRefExpression, ParsedHostBindings, ParseError, parseHostBindings, R3DirectiveMetadata, R3HostDirectiveMetadata, R3InputMetadata, R3QueryMetadata, verifyHostBindings, WrappedNodeExpr} from '@angular/compiler'; import ts from 'typescript'; -import {ErrorCode, FatalDiagnosticError} from '../../../diagnostics'; -import {Reference, ReferenceEmitter} from '../../../imports'; -import {ClassPropertyMapping, HostDirectiveMeta, InputMapping} from '../../../metadata'; +import {ErrorCode, FatalDiagnosticError, makeRelatedInformation} from '../../../diagnostics'; +import {assertSuccessfulReferenceEmit, ImportFlags, Reference, ReferenceEmitter} from '../../../imports'; +import {ClassPropertyMapping, HostDirectiveMeta, InputMapping, InputTransform} from '../../../metadata'; import {DynamicValue, EnumValue, PartialEvaluator, ResolvedValue} from '../../../partial_evaluator'; -import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, filterToMembersWithDecorator, isNamedClassDeclaration, ReflectionHost, reflectObjectLiteral,} from '../../../reflection'; +import {ClassDeclaration, ClassMember, ClassMemberKind, Decorator, filterToMembersWithDecorator, isNamedClassDeclaration, ReflectionHost, reflectObjectLiteral} from '../../../reflection'; import {HandlerFlags} from '../../../transform'; import {createSourceSpan, createValueHasWrongTypeError, forwardRefResolver, getConstructorDependencies, ReferencesRegistry, toR3Reference, tryUnwrapForwardRef, unwrapConstructorDependencies, unwrapExpression, validateConstructorDependencies, wrapFunctionExpressionsInParens, wrapTypeReference,} from '../../common'; @@ -76,9 +76,10 @@ export function extractDirectiveMetadata( // Construct the map of inputs both from the @Directive/@Component // decorator, and the decorated fields. - const inputsFromMeta = parseInputsArray(directive, evaluator); + const inputsFromMeta = parseInputsArray(clazz, directive, evaluator, reflector, refEmitter); const inputsFromFields = parseInputFields( - filterToMembersWithDecorator(decoratedElements, 'Input', coreModule), evaluator); + clazz, filterToMembersWithDecorator(decoratedElements, 'Input', coreModule), evaluator, + reflector, refEmitter); const inputs = ClassPropertyMapping.fromMappedObject({...inputsFromMeta, ...inputsFromFields}); // And outputs. @@ -211,7 +212,7 @@ export function extractDirectiveMetadata( lifecycle: { usesOnChanges, }, - inputs: inputs.toJointMappedObject(), + inputs: inputs.toJointMappedObject(toR3InputMetadata), outputs: outputs.toDirectMappedObject(), queries, viewQueries, @@ -571,8 +572,9 @@ function parseDecoratedFields( /** Parses the `inputs` array of a directive/component decorator. */ function parseInputsArray( - decoratorMetadata: Map, - evaluator: PartialEvaluator): Record { + clazz: ClassDeclaration, decoratorMetadata: Map, + evaluator: PartialEvaluator, reflector: ReflectionHost, + refEmitter: ReferenceEmitter): Record { const inputsField = decoratorMetadata.get('inputs'); if (inputsField === undefined) { @@ -593,12 +595,18 @@ function parseInputsArray( if (typeof value === 'string') { // If the value is a string, we treat it as a mapping string. const [bindingPropertyName, classPropertyName] = parseMappingString(value); - inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required: false}; + inputs[classPropertyName] = { + bindingPropertyName, + classPropertyName, + required: false, + transform: null, + }; } else if (value instanceof Map) { // If it's a map, we treat it as a config object. const name = value.get('name'); const alias = value.get('alias'); const required = value.get('required'); + let transform: InputTransform|null = null; if (typeof name !== 'string') { throw createValueHasWrongTypeError( @@ -606,10 +614,23 @@ function parseInputsArray( `Value at position ${i} of @Directive.inputs array must have a "name" property`); } + if (value.has('transform')) { + const transformValue = value.get('transform'); + + if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) { + throw createValueHasWrongTypeError( + inputsField, transformValue, + `Transform of value at position ${i} of @Directive.inputs array must be a function`); + } + + transform = parseInputTransformFunction(clazz, name, transformValue, reflector, refEmitter); + } + inputs[name] = { classPropertyName: name, bindingPropertyName: typeof alias === 'string' ? alias : name, - required: required === true + required: required === true, + transform, }; } else { throw createValueHasWrongTypeError( @@ -623,13 +644,15 @@ function parseInputsArray( /** Parses the class members that are decorated as inputs. */ function parseInputFields( - inputMembers: {member: ClassMember, decorators: Decorator[]}[], - evaluator: PartialEvaluator): Record { + clazz: ClassDeclaration, inputMembers: {member: ClassMember, decorators: Decorator[]}[], + evaluator: PartialEvaluator, reflector: ReflectionHost, + refEmitter: ReferenceEmitter): Record { const inputs = {} as Record; parseDecoratedFields(inputMembers, evaluator, (classPropertyName, options, decorator) => { let bindingPropertyName: string; let required = false; + let transform: InputTransform|null = null; if (options === null) { bindingPropertyName = classPropertyName; @@ -639,18 +662,135 @@ function parseInputFields( const aliasInConfig = options.get('alias'); bindingPropertyName = typeof aliasInConfig === 'string' ? aliasInConfig : classPropertyName; required = options.get('required') === true; + + if (options.has('transform')) { + const transformValue = options.get('transform'); + + if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) { + throw createValueHasWrongTypeError( + decorator.node, transformValue, `Input transform must be a function`); + } + + transform = parseInputTransformFunction( + clazz, classPropertyName, transformValue, reflector, refEmitter); + } } else { throw createValueHasWrongTypeError( decorator.node, options, `@${decorator.name} decorator argument must resolve to a string or an object literal`); } - inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required}; + inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required, transform}; }); return inputs; } +/** Parses the `transform` function and its type of a specific input. */ +function parseInputTransformFunction( + clazz: ClassDeclaration, classPropertyName: string, value: DynamicValue|Reference, + reflector: ReflectionHost, refEmitter: ReferenceEmitter): InputTransform { + const definition = reflector.getDefinitionOfFunction(value.node); + + if (definition === null) { + throw createValueHasWrongTypeError(value.node, value, 'Input transform must be a function'); + } + + if (definition.typeParameters !== null && definition.typeParameters.length > 0) { + throw createValueHasWrongTypeError( + value.node, value, 'Input transform function cannot be generic'); + } + + if (definition.signatureCount > 1) { + throw createValueHasWrongTypeError( + value.node, value, 'Input transform function cannot have multiple signatures'); + } + + const members = reflector.getMembersOfClass(clazz); + + for (const member of members) { + const conflictingName = `ngAcceptInputType_${classPropertyName}`; + + if (member.name === conflictingName && member.isStatic) { + throw new FatalDiagnosticError( + ErrorCode.CONFLICTING_INPUT_TRANSFORM, value.node, + `Class cannot have both a transform function on Input ${ + classPropertyName} and a static member called ${conflictingName}`); + } + } + + const node = value instanceof Reference ? value.getIdentityIn(clazz.getSourceFile()) : value.node; + + // This should never be null since we know the reference originates + // from the same file, but we null check it just in case. + if (node === null) { + throw createValueHasWrongTypeError( + value.node, value, 'Input transform function could not be referenced'); + } + + // Skip over `this` parameters since they're typing the context, not the actual parameter. + // `this` parameters are guaranteed to be first if they exist, and the only to distinguish them + // is using the name, TS doesn't have a special AST for them. + const firstParam = definition.parameters[0]?.name === 'this' ? definition.parameters[1] : + definition.parameters[0]; + + // Treat functions with no arguments as `unknown` since returning + // the same value from the transform function is valid. + if (!firstParam) { + return {node, type: ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)}; + } + + // This should be caught by `noImplicitAny` already, but null check it just in case. + if (!firstParam.type) { + throw createValueHasWrongTypeError( + value.node, value, 'Input transform function first parameter must have a type'); + } + + if (firstParam.node.dotDotDotToken) { + throw createValueHasWrongTypeError( + value.node, value, 'Input transform function first parameter cannot be a spread parameter'); + } + + assertEmittableInputType(firstParam.type, clazz.getSourceFile(), reflector, refEmitter); + + return {node, type: firstParam.type}; +} + +/** + * Verifies that a type and all types contained within + * it can be referenced in a specific context file. + */ +function assertEmittableInputType( + type: ts.TypeNode, contextFile: ts.SourceFile, reflector: ReflectionHost, + refEmitter: ReferenceEmitter): void { + (function walk(node: ts.Node) { + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const declaration = reflector.getDeclarationOfIdentifier(node.typeName); + + if (declaration !== null) { + // If the type is declared in a different file, we have to check that it can be imported + // into the context file. If they're in the same file, we need to verify that they're + // exported, otherwise TS won't emit it to the .d.ts. + if (declaration.node.getSourceFile() !== contextFile) { + const emittedType = refEmitter.emit( + new Reference(declaration.node), contextFile, + ImportFlags.NoAliasing | ImportFlags.AllowTypeImports | + ImportFlags.AllowRelativeDtsImports); + + assertSuccessfulReferenceEmit(emittedType, node, 'type'); + } else if (!reflector.isStaticallyExported(declaration.node)) { + throw new FatalDiagnosticError( + ErrorCode.SYMBOL_NOT_EXPORTED, type, + `Symbol must be exported in order to be used as the type of an Input transform function`, + [makeRelatedInformation(declaration.node, `The symbol is declared here.`)]); + } + } + } + + node.forEachChild(walk); + })(type); +} + /** Parses the `outputs` array of a directive/component. */ function parseOutputsArray( directive: Map, evaluator: PartialEvaluator): Record { @@ -792,3 +932,14 @@ function toHostDirectiveMetadata( outputs: hostDirective.outputs || null }; } + +/** Converts the parsed input information into metadata. */ +function toR3InputMetadata(mapping: InputMapping): R3InputMetadata { + return { + classPropertyName: mapping.classPropertyName, + bindingPropertyName: mapping.bindingPropertyName, + required: mapping.required, + transformFunction: mapping.transform !== null ? new WrappedNodeExpr(mapping.transform.node) : + null + }; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts b/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts index e92bf697445..f95c7d434a8 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/pipe.ts @@ -183,7 +183,7 @@ export class PipeDecoratorHandler implements const classMetadata = analysis.classMetadata !== null ? compileClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵpipe'); + return compileResults(fac, def, classMetadata, 'ɵpipe', null); } compilePartial(node: ClassDeclaration, analysis: Readonly): CompileResult[] { @@ -192,6 +192,6 @@ export class PipeDecoratorHandler implements const classMetadata = analysis.classMetadata !== null ? compileDeclareClassMetadata(analysis.classMetadata).toStmt() : null; - return compileResults(fac, def, classMetadata, 'ɵpipe'); + return compileResults(fac, def, classMetadata, 'ɵpipe', null); } } diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 137b7c87eb8..330656b624c 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -107,6 +107,13 @@ export enum ErrorCode { */ HOST_DIRECTIVE_MISSING_REQUIRED_BINDING = 2019, + /** + * Raised when a component specifies both a `transform` function on an input + * and has a corresponding `ngAcceptInputType_` member for the same input. + */ + CONFLICTING_INPUT_TRANSFORM = 2020, + + SYMBOL_NOT_EXPORTED = 3001, /** * Raised when a relationship between directives and/or pipes would cause a cyclic import to be diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 408be3dbc31..21e845a2ccd 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts @@ -132,7 +132,16 @@ export enum MatchSource { } /** Metadata for a single input mapping. */ -export type InputMapping = InputOrOutput&{required: boolean}; +export type InputMapping = InputOrOutput&{ + required: boolean; + transform: InputTransform|null +}; + +/** Metadata for an input's transform function. */ +export interface InputTransform { + node: ts.Node; + type: ts.TypeNode; +} /** * Metadata collected for a directive within an NgModule's scope. diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index 87be94b4175..eb91b3bb2b8 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -187,24 +187,31 @@ function readInputsType(type: ts.TypeNode): Record { } const stringValue = readStringType(member.type); + const classPropertyName = member.name.text; // Before v16 the inputs map has the type of `{[field: string]: string}`. // After v16 it has the type of `{[field: string]: {alias: string, required: boolean}}`. if (stringValue != null) { - inputsMap[member.name.text] = { + inputsMap[classPropertyName] = { bindingPropertyName: stringValue, - classPropertyName: member.name.text, - required: false + classPropertyName, + required: false, + // Input transform are only tracked for locally-compiled directives. Directives coming + // from the .d.ts already have them included through `ngAcceptInputType` class members. + transform: null, }; } else { const config = readMapType(member.type, innerValue => { return readStringType(innerValue) ?? readBooleanType(innerValue); }) as {alias: string, required: boolean}; - inputsMap[member.name.text] = { - classPropertyName: member.name.text, + inputsMap[classPropertyName] = { + classPropertyName, bindingPropertyName: config.alias, - required: config.required + required: config.required, + // Input transform are only tracked for locally-compiled directives. Directives coming + // from the .d.ts already have them included through `ngAcceptInputType` class members. + transform: null, }; } } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/host_directives_resolver.ts b/packages/compiler-cli/src/ngtsc/metadata/src/host_directives_resolver.ts index c6bf1bdf6db..ff1248bb7b7 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/host_directives_resolver.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/host_directives_resolver.ts @@ -99,7 +99,8 @@ function resolveInput(bindingName: string, binding: InputMapping): InputMapping return { bindingPropertyName: bindingName, classPropertyName: binding.classPropertyName, - required: binding.required + required: binding.required, + transform: binding.transform, }; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/property_mapping.ts b/packages/compiler-cli/src/ngtsc/metadata/src/property_mapping.ts index 8b211c9cb5c..645d48001d5 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/property_mapping.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/property_mapping.ts @@ -169,11 +169,12 @@ export class ClassPropertyMapping imple * names if they differ. * * This object format is used when mappings are serialized (for example into .d.ts files). + * @param transform Function used to transform the values of the generated map. */ - toJointMappedObject(): {[classPropertyName: string]: T} { - const obj: {[classPropertyName: string]: T} = {}; + toJointMappedObject(transform: (value: T) => O): {[classPropertyName: string]: O} { + const obj: {[classPropertyName: string]: O} = {}; for (const [classPropertyName, inputOrOutput] of this.forwardMap) { - obj[classPropertyName] = inputOrOutput; + obj[classPropertyName] = transform(inputOrOutput); } return obj; } diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts index f56032b826a..7e125da8b84 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts @@ -129,7 +129,7 @@ export function extractDirectiveTypeCheckMeta( const stringLiteralInputFields = new Set(); const undeclaredInputFields = new Set(); - for (const classPropertyName of inputs.classPropertyNames) { + for (const {classPropertyName, transform} of inputs) { const field = members.find(member => member.name === classPropertyName); if (field === undefined || field.node === null) { undeclaredInputFields.add(classPropertyName); @@ -141,6 +141,9 @@ export function extractDirectiveTypeCheckMeta( if (field.nameNode !== null && ts.isStringLiteral(field.nameNode)) { stringLiteralInputFields.add(classPropertyName); } + if (transform !== null) { + coercedInputFields.add(classPropertyName); + } } const arity = reflector.getGenericArityOfClass(node); diff --git a/packages/compiler-cli/src/ngtsc/reflection/src/host.ts b/packages/compiler-cli/src/ngtsc/reflection/src/host.ts index 9833af3a0fa..3b706ca0089 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/src/host.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/src/host.ts @@ -418,6 +418,11 @@ export interface FunctionDefinition { * Generic type parameters of the function. */ typeParameters: ts.TypeParameterDeclaration[]|null; + + /** + * Number of known signatures of the function. + */ + signatureCount: number; } /** diff --git a/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts b/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts index db44b341bad..3546c6f4a28 100644 --- a/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts +++ b/packages/compiler-cli/src/ngtsc/reflection/src/typescript.ts @@ -176,9 +176,13 @@ export class TypeScriptReflectionHost implements ReflectionHost { [ts.factory.createReturnStatement(node.body)]; } + const type = this.checker.getTypeAtLocation(node); + const signatures = this.checker.getSignaturesOfType(type, ts.SignatureKind.Call); + return { node, body, + signatureCount: signatures.length, typeParameters: node.typeParameters === undefined ? null : Array.from(node.typeParameters), parameters: node.parameters.map(param => { const name = parameterName(param.name); diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts index c435add5a14..548b78ecd3b 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -261,7 +261,7 @@ export interface AnalysisOutput { */ export interface CompileResult { name: string; - initializer: Expression; + initializer: Expression|null; statements: Statement[]; type: Type; } diff --git a/packages/compiler-cli/src/ngtsc/transform/src/transform.ts b/packages/compiler-cli/src/ngtsc/transform/src/transform.ts index 4fd0f8a7a13..90193f1f8eb 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/transform.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/transform.ts @@ -105,6 +105,11 @@ class IvyTransformationVisitor extends Visitor { const members = [...node.members]; for (const field of this.classCompilationMap.get(node)!) { + // Type-only member. + if (field.initializer === null) { + continue; + } + // Translate the initializer for the field into TS nodes. const exprNode = translateExpression(field.initializer, this.importManager, translateOptions); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts index 211f9c267b8..5bdc53ef040 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/api.ts @@ -100,7 +100,7 @@ export interface TypeCtorMetadata { /** * Input, output, and query field names in the type which should be included as constructor input. */ - fields: {inputs: string[]; outputs: string[]; queries: string[];}; + fields: {inputs: ClassPropertyMapping; queries: string[];}; /** * `Set` of field names which have type coercion enabled. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts index 6c47c24ee0a..075e7d28c03 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts @@ -247,8 +247,7 @@ export class TypeCheckContextImpl implements TypeCheckContext { // it comes from a .d.ts file. .d.ts declarations don't have bodies. body: !dirNode.getSourceFile().isDeclarationFile, fields: { - inputs: dir.inputs.classPropertyNames, - outputs: dir.outputs.classPropertyNames, + inputs: dir.inputs, // TODO(alxhub): support queries queries: dir.queries, }, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts index 32a7ffe0c18..bc97ea3ae2d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts @@ -77,8 +77,7 @@ export class Environment implements ReferenceEmitEnvironment { fnName, body: true, fields: { - inputs: dir.inputs.classPropertyNames, - outputs: dir.outputs.classPropertyNames, + inputs: dir.inputs, // TODO: support queries queries: dir.queries, }, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 89d6d7340a9..9db27dbccbd 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstIcu, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; +import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstIcu, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, TransplantedType} from '@angular/compiler'; import ts from 'typescript'; import {Reference} from '../../imports'; @@ -706,7 +706,7 @@ class TcbDirectiveInputsOp extends TcbOp { let assignment: ts.Expression = wrapForDiagnostics(expr); - for (const {fieldName, required} of attr.inputs) { + for (const {fieldName, required, transformType} of attr.inputs) { let target: ts.LeftHandSideExpression; if (required) { @@ -714,18 +714,26 @@ class TcbDirectiveInputsOp extends TcbOp { } if (this.dir.coercedInputFields.has(fieldName)) { - // The input has a coercion declaration which should be used instead of assigning the - // expression into the input field directly. To achieve this, a variable is declared - // with a type of `typeof Directive.ngAcceptInputType_fieldName` which is then used as - // target of the assignment. - const dirTypeRef = this.tcb.env.referenceType(this.dir.ref); - if (!ts.isTypeReferenceNode(dirTypeRef)) { - throw new Error( - `Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`); + let type: ts.TypeNode; + + if (transformType) { + type = this.tcb.env.referenceTransplantedType(new TransplantedType(transformType)); + } else { + // The input has a coercion declaration which should be used instead of assigning the + // expression into the input field directly. To achieve this, a variable is declared + // with a type of `typeof Directive.ngAcceptInputType_fieldName` which is then used as + // target of the assignment. + const dirTypeRef: ts.TypeNode = this.tcb.env.referenceType(this.dir.ref); + + if (!ts.isTypeReferenceNode(dirTypeRef)) { + throw new Error( + `Expected TypeReferenceNode from reference to ${this.dir.ref.debugName}`); + } + + type = tsCreateTypeQueryForCoercedInput(dirTypeRef.typeName, fieldName); } const id = this.tcb.allocateId(); - const type = tsCreateTypeQueryForCoercedInput(dirTypeRef.typeName, fieldName); this.scope.addStatement(tsDeclareVariable(id, type)); target = id; @@ -1664,7 +1672,7 @@ class Scope { interface TcbBoundAttribute { attribute: TmplAstBoundAttribute|TmplAstTextAttribute; - inputs: {fieldName: ClassPropertyName, required: boolean}[]; + inputs: {fieldName: ClassPropertyName, required: boolean, transformType: ts.TypeNode|null}[]; } /** @@ -1874,8 +1882,11 @@ function getBoundAttributes( if (inputs !== null) { boundInputs.push({ attribute: attr, - inputs: - inputs.map(input => ({fieldName: input.classPropertyName, required: input.required})) + inputs: inputs.map(input => ({ + fieldName: input.classPropertyName, + required: input.required, + transformType: input.transform?.type || null + })) }); } }; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts index 80a5636c08c..d3792fdeed7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts @@ -135,18 +135,22 @@ function constructTypeCtorParameter( // In the special case there are no inputs, initType is set to {}. let initType: ts.TypeNode|null = null; - const keys: string[] = meta.fields.inputs; const plainKeys: ts.LiteralTypeNode[] = []; const coercedKeys: ts.PropertySignature[] = []; - for (const key of keys) { - if (!meta.coercedInputFields.has(key)) { - plainKeys.push(ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(key))); + + for (const {classPropertyName, transform} of meta.fields.inputs) { + if (!meta.coercedInputFields.has(classPropertyName)) { + plainKeys.push( + ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(classPropertyName))); } else { coercedKeys.push(ts.factory.createPropertySignature( /* modifiers */ undefined, - /* name */ key, + /* name */ classPropertyName, /* questionToken */ undefined, - /* type */ tsCreateTypeQueryForCoercedInput(rawType.typeName, key))); + /* type */ + transform == null ? + tsCreateTypeQueryForCoercedInput(rawType.typeName, classPropertyName) : + transform.type)); } } if (plainKeys.length > 0) { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts index 499d507785d..df94a10995c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts @@ -870,7 +870,12 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true}, + input: { + classPropertyName: 'input', + bindingPropertyName: 'input', + required: true, + transform: null, + }, }, }]); @@ -897,11 +902,17 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true}, + input: { + classPropertyName: 'input', + bindingPropertyName: 'input', + required: true, + transform: null, + }, otherInput: { classPropertyName: 'otherInput', bindingPropertyName: 'otherInput', - required: true + required: true, + transform: null, } } }, @@ -913,7 +924,8 @@ class TestComponent { otherDirInput: { classPropertyName: 'otherDirInput', bindingPropertyName: 'otherDirInput', - required: true + required: true, + transform: null, } }, } @@ -938,7 +950,12 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'inputAlias', required: true} + input: { + classPropertyName: 'input', + bindingPropertyName: 'inputAlias', + required: true, + transform: null, + } } }]); @@ -962,7 +979,12 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true}, + input: { + classPropertyName: 'input', + bindingPropertyName: 'input', + required: true, + transform: null, + }, } }]); @@ -988,7 +1010,8 @@ class TestComponent { input: { classPropertyName: 'input', bindingPropertyName: 'inputAlias', - required: true + required: true, + transform: null, }, }, }]); @@ -1009,7 +1032,12 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true}, + input: { + classPropertyName: 'input', + bindingPropertyName: 'input', + required: true, + transform: null, + }, } }]); @@ -1032,7 +1060,12 @@ class TestComponent { name: 'Dir', selector: '[dir]', inputs: { - input: {classPropertyName: 'input', bindingPropertyName: 'input', required: true}, + input: { + classPropertyName: 'input', + bindingPropertyName: 'input', + required: true, + transform: null, + }, }, outputs: {inputChange: 'inputChange'}, }]); @@ -1053,7 +1086,14 @@ class TestComponent { type: 'directive', name: 'Dir', selector: '[dir]', - inputs: {dir: {classPropertyName: 'dir', bindingPropertyName: 'dir', required: true}} + inputs: { + dir: { + classPropertyName: 'dir', + bindingPropertyName: 'dir', + required: true, + transform: null, + } + } }]); expect(messages).toEqual([]); @@ -1080,7 +1120,8 @@ class TestComponent { input: { classPropertyName: 'input', bindingPropertyName: 'hostAlias', - required: true + required: true, + transform: null, }, }, isStandalone: true, @@ -1111,7 +1152,8 @@ class TestComponent { maxlength: { classPropertyName: 'maxlength', bindingPropertyName: 'maxlength', - required: true + required: true, + transform: null, }, }, }]); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index f1585857c1a..ae58f88a9e7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import ts from 'typescript'; + import {initMockFileSystem} from '../../file_system/testing'; import {TypeCheckingConfig} from '../api'; import {ALL_ENABLED_CONFIG, tcb, TestDeclaration, TestDirective} from '../testing'; @@ -595,6 +597,37 @@ describe('type check blocks', () => { '_t1 = (((this).foo));'); }); + it('should use transform type if an input has one', () => { + const TEMPLATE = `
`; + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'Dir', + selector: '[dir]', + inputs: { + fieldA: { + bindingPropertyName: 'fieldA', + classPropertyName: 'fieldA', + required: false, + transform: { + node: ts.factory.createFunctionDeclaration( + undefined, undefined, undefined, undefined, [], undefined, undefined), + type: ts.factory.createUnionTypeNode([ + ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ]) + }, + }, + }, + coercedInputFields: ['fieldA'], + }]; + + const block = tcb(TEMPLATE, DIRECTIVES); + + expect(block).toContain( + 'var _t1: boolean | string = null!; ' + + '_t1 = (((this).expr));'); + }); + it('should handle $any casts', () => { const TEMPLATE = `{{$any(a)}}`; const block = tcb(TEMPLATE); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts index ec8249fa08e..8fb26caa7d4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts @@ -7,9 +7,10 @@ */ import ts from 'typescript'; -import {absoluteFrom, AbsoluteFsPath, getFileSystem, getSourceFileOrError, LogicalFileSystem, NgtscCompilerHost} from '../../file_system'; +import {absoluteFrom, getFileSystem, getSourceFileOrError, LogicalFileSystem, NgtscCompilerHost} from '../../file_system'; import {runInEachFileSystem, TestFile} from '../../file_system/testing'; import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, ModuleResolver, Reference, ReferenceEmitter} from '../../imports'; +import {ClassPropertyMapping, InputMapping} from '../../metadata'; import {NOOP_PERF_RECORDER} from '../../perf'; import {TsCreateProgramDriver, UpdateMode} from '../../program_driver'; import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection'; @@ -18,14 +19,12 @@ import {getRootDirs} from '../../util/src/typescript'; import {InliningMode, PendingFileTypeCheckingData, TypeCheckContextImpl, TypeCheckingHost} from '../src/context'; import {TemplateSourceManager} from '../src/source'; import {TypeCheckFile} from '../src/type_check_file'; - import {ALL_ENABLED_CONFIG} from '../testing'; runInEachFileSystem(() => { describe('ngtsc typechecking', () => { let _: typeof absoluteFrom; let LIB_D_TS: TestFile; - let TYPE_CHECK_TS: TestFile; beforeEach(() => { _ = absoluteFrom; @@ -83,8 +82,7 @@ TestClass.ngTypeCtor({value: 'test'}); fnName: 'ngTypeCtor', body: true, fields: { - inputs: ['value'], - outputs: [], + inputs: ClassPropertyMapping.fromMappedObject({value: 'value'}), queries: [], }, coercedInputFields: new Set(), @@ -121,8 +119,7 @@ TestClass.ngTypeCtor({value: 'test'}); fnName: 'ngTypeCtor', body: true, fields: { - inputs: ['value'], - outputs: [], + inputs: ClassPropertyMapping.fromMappedObject({value: 'value'}), queries: ['queryField'], }, coercedInputFields: new Set(), @@ -166,11 +163,26 @@ TestClass.ngTypeCtor({value: 'test'}); fnName: 'ngTypeCtor', body: true, fields: { - inputs: ['foo', 'bar'], - outputs: [], + inputs: ClassPropertyMapping.fromMappedObject({ + foo: 'foo', + bar: 'bar', + baz: { + classPropertyName: 'baz', + bindingPropertyName: 'baz', + required: false, + transform: { + type: ts.factory.createUnionTypeNode([ + ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ]), + node: ts.factory.createFunctionDeclaration( + undefined, undefined, undefined, undefined, [], undefined, undefined) + } + } + }), queries: [], }, - coercedInputFields: new Set(['bar']), + coercedInputFields: new Set(['bar', 'baz']), }); const programStrategy = new TsCreateProgramDriver(program, host, options, []); programStrategy.updateFiles(ctx.finalize(), UpdateMode.Complete); @@ -179,7 +191,7 @@ TestClass.ngTypeCtor({value: 'test'}); const typeCtor = TestClassWithCtor.members.find(isTypeCtor)!; const ctorText = typeCtor.getText().replace(/[ \r\n]+/g, ' '); expect(ctorText).toContain( - 'init: Pick & { bar: typeof TestClass.ngAcceptInputType_bar; }'); + 'init: Pick & { bar: typeof TestClass.ngAcceptInputType_bar; baz: boolean | string; }'); }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index bc3af957537..81f8ca30e5b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -13,7 +13,7 @@ import {absoluteFrom, AbsoluteFsPath, getSourceFileOrError, LogicalFileSystem} f import {TestFile} from '../../file_system/testing'; import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, ModuleResolver, Reference, ReferenceEmitter, RelativePathStrategy} from '../../imports'; import {NOOP_INCREMENTAL_BUILD} from '../../incremental'; -import {ClassPropertyMapping, CompoundMetadataReader, DirectiveMeta, HostDirectivesResolver, InputMapping, MatchSource, MetadataReaderWithIndex, MetaKind, NgModuleIndex} from '../../metadata'; +import {ClassPropertyMapping, CompoundMetadataReader, DirectiveMeta, HostDirectivesResolver, InputMapping, InputTransform, MatchSource, MetadataReaderWithIndex, MetaKind, NgModuleIndex} from '../../metadata'; import {NOOP_PERF_RECORDER} from '../../perf'; import {TsCreateProgramDriver} from '../../program_driver'; import {ClassDeclaration, isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection'; @@ -236,7 +236,10 @@ export interface TestDirective extends Partial; } +/**************************************************************************************************** + * PARTIAL FILE: input_transform.js + ****************************************************************************************************/ +import { Directive, Input, NgModule } from '@angular/core'; +import * as i0 from "@angular/core"; +function toNumber(value) { + return value ? 1 : 0; +} +class MyDirective { +} +MyDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); +MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyDirective, selector: "[my-directive]", inputs: { functionDeclarationInput: ["functionDeclarationInput", "functionDeclarationInput", toNumber], inlineFunctionInput: ["inlineFunctionInput", "inlineFunctionInput", (value, _) => value ? 1 : 0] }, ngImport: i0 }); +export { MyDirective }; +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyDirective, decorators: [{ + type: Directive, + args: [{ selector: '[my-directive]' }] + }], propDecorators: { functionDeclarationInput: [{ + type: Input, + args: [{ transform: toNumber }] + }], inlineFunctionInput: [{ + type: Input, + args: [{ transform: (value, _) => value ? 1 : 0 }] + }] } }); +class MyModule { +} +MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); +MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyDirective] }); +MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule }); +export { MyModule }; +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{ + type: NgModule, + args: [{ declarations: [MyDirective] }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: input_transform.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyDirective { + functionDeclarationInput: any; + inlineFunctionInput: any; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; + static ngAcceptInputType_functionDeclarationInput: number | string; + static ngAcceptInputType_inlineFunctionInput: string | number; +} +export declare class MyModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json index 06b295846fb..51ccf85bc7e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json @@ -34,6 +34,23 @@ "failureMessage": "Incorrect directive definition" } ] + }, + { + "description": "should declare inputs with transform functions", + "inputFiles": [ + "input_transform.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "input_transform_definition.js", + "generated": "input_transform.js" + } + ], + "failureMessage": "Incorrect directive definition" + } + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform.ts new file mode 100644 index 00000000000..808a076a3c0 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform.ts @@ -0,0 +1,18 @@ +import {Directive, Input, NgModule} from '@angular/core'; + +function toNumber(value: number|string) { + return value ? 1 : 0; +} + +@Directive({selector: '[my-directive]'}) +export class MyDirective { + @Input({transform: toNumber}) functionDeclarationInput: any; + + // There's an extra `_` parameter, because full compilation strips the parentheses around the + // parameters while partial compilation keeps them. This ensures consistent output. + @Input({transform: (value: string|number, _: any) => value ? 1 : 0}) inlineFunctionInput: any; +} + +@NgModule({declarations: [MyDirective]}) +export class MyModule { +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform_definition.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform_definition.js new file mode 100644 index 00000000000..3e09144fd6b --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform_definition.js @@ -0,0 +1,8 @@ +MyDirective.ɵdir = /*@__PURE__*/ $r3$.ɵɵdefineDirective({ + … + inputs: { + functionDeclarationInput: ["functionDeclarationInput", "functionDeclarationInput", toNumber], + inlineFunctionInput: ["inlineFunctionInput", "inlineFunctionInput", (value, _) => value ? 1 : 0] + }, + features: [$r3$.ɵɵInputTransformsFeature]… +}); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 10cac1642c7..5a2f8c377da 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -2336,6 +2336,242 @@ function allTests(os: string) { verifyThrownError( ErrorCode.DECORATOR_ARG_NOT_LITERAL, '@Injectable argument must be an object literal'); }); + + it('should produce a diangostic if the transform value is not a function', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + const NOT_A_FUNCTION = 1; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: NOT_A_FUNCTION}) value!: number; + } + `); + + verifyThrownError(ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform must be a function`); + }); + + it('should produce a diangostic if the transform value in the inputs array is not a function', + () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + const NOT_A_FUNCTION = 1; + + @Directive({ + selector: '[dir]', + standalone: true, + inputs: [{ + name: 'value', + transform: NOT_A_FUNCTION + }] + }) + export class Dir { + value!: number; + } + `); + + verifyThrownError( + ErrorCode.VALUE_HAS_WRONG_TYPE, + `Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'number'.`); + }); + + it('should produce a diangostic if the transform function first parameter has no arguments', + () => { + env.tsconfig({noImplicitAny: false}); + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: (val) => 1}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.VALUE_HAS_WRONG_TYPE, + `Input transform function first parameter must have a type`); + }); + + it('should produce a diangostic if the transform function is generic', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: (val: T) => 1}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform function cannot be generic`); + }); + + it('should produce a diangostic if there is a conflicting coercion member', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: (val: string) => 1}) value!: number; + + static ngAcceptInputType_value: boolean; + } + `); + + verifyThrownError( + ErrorCode.CONFLICTING_INPUT_TRANSFORM, + `Class cannot have both a transform function on Input value and a static member called ngAcceptInputType_value`); + }); + + it('should produce a diangostic if the transform function type cannot be referenced from the source file', + () => { + env.write('/util.ts', ` + interface InternalType { + foo: boolean; + } + + export function toNumber(val: InternalType) { return 1; } + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {toNumber} from './util'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.'); + }); + + it('should produce a diangostic if a sub-type of the transform function cannot be referenced from the source file', + () => { + env.write('/util.ts', ` + interface InternalType { + foo: boolean; + } + + export function toNumber(val: {value: InternalType}) { return 1; } + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {toNumber} from './util'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.'); + }); + + it('should produce a diangostic if a generic parameter of the transform function cannot be referenced from the source file', + () => { + env.write('/util.ts', ` + export interface GenericWrapper { + value: T; + } + + interface InternalType { + foo: boolean; + } + + export function toNumber(val: GenericWrapper) { return 1; } + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {toNumber} from './util'; + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.IMPORT_GENERATION_FAILURE, 'Unable to import type InternalType.'); + }); + + it('should produce a diangostic if transform type is not exported', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + interface InternalType { + foo: boolean; + } + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: (val: InternalType) => 1}) val!: number; + } + `); + + verifyThrownError( + ErrorCode.SYMBOL_NOT_EXPORTED, + 'Symbol must be exported in order to be used as the type of an Input transform function'); + }); + + it('should produce a diangostic if the transform value is not a function', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function createTransform(outerValue: number) { + return (innerValue: string) => outerValue; + } + + @Directive({selector: '[dir]', standalone: true}) + export class Dir { + @Input({transform: createTransform(1)}) value!: number; + } + `); + + verifyThrownError(ErrorCode.VALUE_HAS_WRONG_TYPE, `Input transform must be a function`); + }); + + it('should produce a diangostic if the first parameter of a transform is a spread', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(...value: (string | boolean)[]) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.VALUE_HAS_WRONG_TYPE, + `Input transform function first parameter cannot be a spread parameter`); + }); + + it('should produce a diangostic if a transform function has multiple signatures', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(value: boolean): number; + function toNumber(value: string): number; + function toNumber(value: boolean | string) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + verifyThrownError( + ErrorCode.VALUE_HAS_WRONG_TYPE, + `Input transform function cannot have multiple signatures`); + }); }); describe('multiple decorators on classes', () => { @@ -8286,6 +8522,233 @@ function allTests(os: string) { expectedImports.replace(/\s/g, '')}]});`); }); }); + + describe('input transforms', () => { + it('should compile a directive input with a transform function', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(value: boolean | string) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;'); + }); + + it('should compile a component input with a transform function', () => { + env.write('/test.ts', ` + import {Component, Input} from '@angular/core'; + + function toNumber(value: boolean | string) { return 1; } + + @Component({standalone: true, template: 'hello'}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents) + .toContain('features: [i0.ɵɵStandaloneFeature, i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;'); + }); + + it('should compile an input with a transform function that contains a generic parameter', + () => { + env.write('/types.ts', ` + export interface GenericWrapper { + value: T; + } + `); + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {GenericWrapper} from './types'; + + function toNumber(value: boolean | string | GenericWrapper) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('import * as i1 from "./types"'); + expect(dtsContents) + .toContain( + 'static ngAcceptInputType_value: boolean | string | i1.GenericWrapper;'); + }); + + it('should compile an input with a transform function that contains nested generic parameters', + () => { + env.write('/types.ts', ` + export interface GenericWrapper { + value: T; + } + `); + env.write('/other-types.ts', ` + export class GenericClass { + foo: T; + } + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {GenericWrapper} from './types'; + import {GenericClass} from './other-types'; + + function toNumber(value: boolean | string | GenericWrapper>) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('import * as i1 from "./types"'); + expect(dtsContents).toContain('import * as i2 from "./other-types"'); + expect(dtsContents) + .toContain( + 'static ngAcceptInputType_value: boolean | string | i1.GenericWrapper>;'); + }); + + it('should compile an input with an external transform function', () => { + env.write('node_modules/external/index.d.ts', ` + export interface ExternalObj { + foo: boolean; + } + + export type ExternalToNumberType = string | boolean | ExternalObj; + + export declare function externalToNumber(val: ExternalToNumberType): number; + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {externalToNumber} from 'external'; + + @Directive({standalone: true}) + export class Dir { + @Input({transform: externalToNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain(`import { externalToNumber } from 'external';`); + expect(jsContents).toContain('inputs: { value: ["value", "value", externalToNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";'); + expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;'); + }); + + it('should compile an input with an inline transform function', () => { + env.write('node_modules/external/index.d.ts', ` + export interface ExternalObj { + foo: boolean; + } + + export type ExternalToNumberType = string | boolean | ExternalObj; + `); + + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + import {ExternalToNumberType} from 'external'; + + @Directive({standalone: true}) + export class Dir { + @Input({transform: (value: ExternalToNumberType) => value ? 1 : 0}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents) + .toContain('inputs: { value: ["value", "value", (value) => value ? 1 : 0] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('import * as i1 from "./node_modules/external/index";'); + expect(dtsContents).toContain('static ngAcceptInputType_value: i1.ExternalToNumberType;'); + }); + + it('should compile a directive input with a transform function with a `this` typing', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(this: Dir, value: boolean | string) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('static ngAcceptInputType_value: boolean | string;'); + }); + + it('should treat an input transform function only with a `this` parameter as unknown', () => { + env.write('/test.ts', ` + import {Directive, Input} from '@angular/core'; + + function toNumber(this: Dir) { return 1; } + + @Directive({standalone: true}) + export class Dir { + @Input({transform: toNumber}) value!: number; + } + `); + + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + + expect(jsContents).toContain('inputs: { value: ["value", "value", toNumber] }'); + expect(jsContents).toContain('features: [i0.ɵɵInputTransformsFeature]'); + expect(dtsContents).toContain('static ngAcceptInputType_value: unknown;'); + }); + }); }); function expectTokenAtPosition( diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index ad068bb12ce..2fd6c994b63 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -336,7 +336,6 @@ export declare class AnimationEvent { export class Module {} `); const diags = env.driveDiagnostics(); - console.error(diags); expect(diags.length).toBe(0); }); @@ -1695,7 +1694,12 @@ export declare class AnimationEvent { `); }); - it('should coerce an input using a coercion function if provided', () => { + function getDiagnosticLines(diag: ts.Diagnostic): string[] { + const separator = '~~~~~'; + return ts.flattenDiagnosticMessageText(diag.messageText, separator).split(separator); + } + + it('should coerce an input using a transform function if provided', () => { env.write('test.ts', ` import {Component, NgModule} from '@angular/core'; import {MatInputModule} from '@angular/material'; @@ -1810,6 +1814,509 @@ export declare class AnimationEvent { expect(diags[0].messageText) .toBe(`Type 'undefined' is not assignable to type 'string'.`); }); + + it('should type check using the first parameter type of a simple transform function', () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + export function toNumber(val: boolean | string) { return 1; } + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: toNumber}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = 1; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toBe(`Type 'number' is not assignable to type 'string | boolean'.`); + }); + + it('should type checking using the first parameter type of a simple inline transform function', + () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: (val: boolean | string) => 1}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = 1; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toBe(`Type 'number' is not assignable to type 'string | boolean'.`); + }); + + it('should type check using the transform function specified in the `inputs` array', () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + export function toNumber(val: boolean | string) { return 1; } + + @Directive({ + selector: '[dir]', + standalone: true, + inputs: [{ + name: 'val', + transform: toNumber + }] + }) + export class CoercionDir { + val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = 1; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toBe(`Type 'number' is not assignable to type 'string | boolean'.`); + }); + + it('should type check using the first parameter type of a built-in function', () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: parseInt}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = 1; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe(`Type 'number' is not assignable to type 'string'.`); + }); + + it('should type check an imported transform function with a complex type', () => { + env.tsconfig({strictTemplates: true}); + + env.write('types.ts', ` + export class ComplexObjValue { + foo: boolean; + } + + export interface ComplexObj { + value: ComplexObjValue; + } + `); + + env.write('utils.ts', ` + import {ComplexObj} from './types'; + + export type ToNumberType = string | boolean | ComplexObj; + + export function toNumber(val: ToNumberType) { return 1; } + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {toNumber} from './utils'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: toNumber}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: { + foo: 'hello' + } + }; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: { foo: string; }; }' is not assignable to type 'ToNumberType'.`, + ` Type '{ value: { foo: string; }; }' is not assignable to type 'ComplexObj'.`, + ` The types of 'value.foo' are incompatible between these types.`, + ` Type 'string' is not assignable to type 'boolean'.` + ]); + }); + + it('should type check an imported transform function with a complex type from an external library', + () => { + env.tsconfig({strictTemplates: true}); + + env.write('node_modules/external/index.d.ts', ` + export class ExternalComplexObjValue { + foo: boolean; + } + + export interface ExternalComplexObj { + value: ExternalComplexObjValue; + } + + export type ExternalToNumberType = string | boolean | ExternalComplexObj; + + export declare function externalToNumber(val: ExternalToNumberType): number; + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {externalToNumber} from 'external'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: externalToNumber}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: { + foo: 'hello' + } + }; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: { foo: string; }; }' is not assignable to type 'ExternalToNumberType'.`, + ` Type '{ value: { foo: string; }; }' is not assignable to type 'ExternalComplexObj'.`, + ` The types of 'value.foo' are incompatible between these types.`, + ` Type 'string' is not assignable to type 'boolean'.` + ]); + }); + + it('should type check an input with a generic transform type', () => { + env.tsconfig({strictTemplates: true}); + + env.write('generics.ts', ` + export interface GenericWrapper { + value: T; + } + `); + + env.write('types.ts', ` + export class ExportedClass { + foo: boolean; + } + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {GenericWrapper} from './generics'; + import {ExportedClass} from './types'; + + export interface LocalInterface { + foo: string; + } + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: (val: GenericWrapper) => 1}) importedVal!: number; + @Input({transform: (val: GenericWrapper) => 1}) localVal!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: { + foo: 1 + } + }; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: { foo: number; }; }' is not assignable to type 'GenericWrapper'.`, + ` The types of 'value.foo' are incompatible between these types.`, + ` Type 'number' is not assignable to type 'boolean'.` + ]); + + expect(getDiagnosticLines(diags[1])).toEqual([ + `Type '{ value: { foo: number; }; }' is not assignable to type 'GenericWrapper'.`, + ` The types of 'value.foo' are incompatible between these types.`, + ` Type 'number' is not assignable to type 'string'.` + ]); + }); + + it('should type check an input with a generic transform union type', () => { + env.tsconfig({strictTemplates: true}); + env.write('types.ts', ` + interface GenericWrapper { + value: T; + } + + export type CoercionType = boolean | string | GenericWrapper; + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {CoercionType} from './types'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: (val: CoercionType) => 1}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = {value: 1}; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: number; }' is not assignable to type 'CoercionType'.`, + ` Type '{ value: number; }' is not assignable to type 'GenericWrapper'.`, + ` Types of property 'value' are incompatible.`, + ` Type 'number' is not assignable to type 'string'.` + ]); + }); + + it('should type check an input with a generic transform type from an external library', () => { + env.tsconfig({strictTemplates: true}); + env.write('node_modules/external/index.d.ts', ` + export interface ExternalGenericWrapper { + value: T; + } + + export declare class ExternalClass { + foo: boolean; + } + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {ExternalGenericWrapper, ExternalClass} from 'external'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: (val: ExternalGenericWrapper) => 1}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: { + foo: 1 + } + }; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: { foo: number; }; }' is not assignable to type 'ExternalGenericWrapper'.`, + ` The types of 'value.foo' are incompatible between these types.`, + ` Type 'number' is not assignable to type 'boolean'.` + ]); + }); + + it('should allow any value to be assigned if the transform function has no parameters', + () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: () => 1}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = {}; + } + `); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should type check static inputs against the transform function type', () => { + env.tsconfig({strictTemplates: true}); + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + + export function toNumber(val: number | boolean) { return 1; } + + @Directive({selector: '[dir]', standalone: true}) + export class CoercionDir { + @Input({transform: toNumber}) val!: number; + } + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp {} + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toBe(`Type '"test"' is not assignable to type 'number | boolean'.`); + }); + + it('should type check inputs with a transform function coming from a host directive', () => { + env.tsconfig({strictTemplates: true}); + env.write('host-dir.ts', ` + import {Directive, Input} from '@angular/core'; + + export interface HostDirType { + value: number; + } + + @Directive({standalone: true}) + export class HostDir { + @Input({transform: (val: HostDirType) => 1}) val!: number; + } + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {HostDir} from './host-dir'; + + @Directive({ + selector: '[dir]', + standalone: true, + hostDirectives: [{ + directive: HostDir, + inputs: ['val'] + }] + }) + export class CoercionDir {} + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: 'hello' + }; + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: string; }' is not assignable to type 'HostDirType'.`, + ` Types of property 'value' are incompatible.`, + ` Type 'string' is not assignable to type 'number'.` + ]); + }); + + it('should type check inputs with a transform inherited from a parent class', () => { + env.tsconfig({strictTemplates: true}); + env.write('host-dir.ts', ` + import {Directive, Input} from '@angular/core'; + + export interface ParentType { + value: number; + } + + @Directive({standalone: true}) + export class Parent { + @Input({transform: (val: ParentType) => 1}) val!: number; + } + `); + + env.write('test.ts', ` + import {Component, Directive, Input} from '@angular/core'; + import {Parent} from './host-dir'; + + @Directive({ + selector: '[dir]', + standalone: true + }) + export class CoercionDir extends Parent {} + + @Component({ + template: '', + standalone: true, + imports: [CoercionDir], + }) + export class FooCmp { + invalidType = { + value: 'hello' + }; + } + `); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(getDiagnosticLines(diags[0])).toEqual([ + `Type '{ value: string; }' is not assignable to type 'ParentType'.`, + ` Types of property 'value' are incompatible.`, + ` Type 'string' is not assignable to type 'number'.` + ]); + }); }); describe('restricted inputs', () => { diff --git a/packages/compiler/src/compiler_facade_interface.ts b/packages/compiler/src/compiler_facade_interface.ts index c9325eb08bf..cf40af67d07 100644 --- a/packages/compiler/src/compiler_facade_interface.ts +++ b/packages/compiler/src/compiler_facade_interface.ts @@ -80,12 +80,14 @@ export type InputMap = { bindingPropertyName: string, classPropertyName: string, required: boolean, + transformFunction: InputTransformFunction, }; }; export type Provider = unknown; export type Type = Function; export type OpaqueValue = unknown; +export type InputTransformFunction = any; export enum FactoryTarget { Directive = 0, @@ -191,7 +193,11 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade { export interface R3DeclareDirectiveFacade { selector?: string; type: Type; - inputs?: {[classPropertyName: string]: string|[string, string]}; + inputs?: { + [classPropertyName: string]: string| + [bindingPropertyName: string, + classPropertyName: string, transformFunction?: InputTransformFunction] + }; outputs?: {[classPropertyName: string]: string}; host?: { attributes?: {[key: string]: OpaqueValue}; diff --git a/packages/compiler/src/core.ts b/packages/compiler/src/core.ts index 08129bbe927..3ed08b968d7 100644 --- a/packages/compiler/src/core.ts +++ b/packages/compiler/src/core.ts @@ -33,6 +33,7 @@ export enum ChangeDetectionStrategy { export interface Input { alias?: string; required?: boolean; + transform?: (value: any) => any; } export interface Output { diff --git a/packages/compiler/src/jit_compiler_facade.ts b/packages/compiler/src/jit_compiler_facade.ts index 554bc83afd1..704fd5673d8 100644 --- a/packages/compiler/src/jit_compiler_facade.ts +++ b/packages/compiler/src/jit_compiler_facade.ts @@ -7,7 +7,7 @@ */ -import {CompilerFacade, CoreEnvironment, ExportedCompilerFacade, InputMap, OpaqueValue, R3ComponentMetadataFacade, R3DeclareComponentFacade, R3DeclareDependencyMetadataFacade, R3DeclareDirectiveDependencyFacade, R3DeclareDirectiveFacade, R3DeclareFactoryFacade, R3DeclareInjectableFacade, R3DeclareInjectorFacade, R3DeclareNgModuleFacade, R3DeclarePipeDependencyFacade, R3DeclarePipeFacade, R3DeclareQueryMetadataFacade, R3DependencyMetadataFacade, R3DirectiveMetadataFacade, R3FactoryDefMetadataFacade, R3InjectableMetadataFacade, R3InjectorMetadataFacade, R3NgModuleMetadataFacade, R3PipeMetadataFacade, R3QueryMetadataFacade, R3TemplateDependencyFacade} from './compiler_facade_interface'; +import {CompilerFacade, CoreEnvironment, ExportedCompilerFacade, InputMap, InputTransformFunction, OpaqueValue, R3ComponentMetadataFacade, R3DeclareComponentFacade, R3DeclareDependencyMetadataFacade, R3DeclareDirectiveDependencyFacade, R3DeclareDirectiveFacade, R3DeclareFactoryFacade, R3DeclareInjectableFacade, R3DeclareInjectorFacade, R3DeclareNgModuleFacade, R3DeclarePipeDependencyFacade, R3DeclarePipeFacade, R3DeclareQueryMetadataFacade, R3DependencyMetadataFacade, R3DirectiveMetadataFacade, R3FactoryDefMetadataFacade, R3InjectableMetadataFacade, R3InjectorMetadataFacade, R3NgModuleMetadataFacade, R3PipeMetadataFacade, R3QueryMetadataFacade, R3TemplateDependencyFacade} from './compiler_facade_interface'; import {ConstantPool} from './constant_pool'; import {ChangeDetectionStrategy, HostBinding, HostListener, Input, Output, ViewEncapsulation} from './core'; import {compileInjectable} from './injectable_compiler_2'; @@ -324,7 +324,9 @@ function convertDirectiveFacadeToMetadata(facade: R3DirectiveMetadataFacade): R3 inputsFromType[field] = { bindingPropertyName: ann.alias || field, classPropertyName: field, - required: ann.required || false + required: ann.required || false, + // TODO(crisbeto): resolve transform function reference here. + transformFunction: null, }; } else if (isOutput(ann)) { outputsFromType[field] = ann.alias || field; @@ -646,26 +648,45 @@ function isOutput(value: any): value is Output { return value.ngMetadataName === 'Output'; } -function inputsMappingToInputMetadata(inputs: Record) { +function inputsMappingToInputMetadata( + inputs: Record) { return Object.keys(inputs).reduce((result, key) => { const value = inputs[key]; - result[key] = typeof value === 'string' ? - {bindingPropertyName: value, classPropertyName: value, required: false} : - {bindingPropertyName: value[0], classPropertyName: value[1], required: false}; + + // TODO(crisbeto): resolve transform function reference here. + if (typeof value === 'string') { + result[key] = { + bindingPropertyName: value, + classPropertyName: value, + required: false, + transformFunction: null + }; + } else { + result[key] = { + bindingPropertyName: value[0], + classPropertyName: value[1], + required: false, + transformFunction: null + }; + } + return result; }, {}); } function parseInputsArray(values: (string|{name: string, alias?: string, required?: boolean})[]) { return values.reduce((results, value) => { + // TODO(crisbeto): resolve transform function reference here. if (typeof value === 'string') { const [bindingPropertyName, classPropertyName] = parseMappingString(value); - results[classPropertyName] = {bindingPropertyName, classPropertyName, required: false}; + results[classPropertyName] = + {bindingPropertyName, classPropertyName, required: false, transformFunction: null}; } else { results[value.name] = { bindingPropertyName: value.alias || value.name, classPropertyName: value.name, - required: value.required || false + required: value.required || false, + transformFunction: null }; } return results; diff --git a/packages/compiler/src/render3/partial/api.ts b/packages/compiler/src/render3/partial/api.ts index 3b7675b0483..82f125378c5 100644 --- a/packages/compiler/src/render3/partial/api.ts +++ b/packages/compiler/src/render3/partial/api.ts @@ -45,7 +45,10 @@ export interface R3DeclareDirectiveMetadata extends R3PartialDeclaration { * A mapping of inputs from class property names to binding property names, or to a tuple of * binding property name and class property name if the names are different. */ - inputs?: {[classPropertyName: string]: string|[string, string]}; + inputs?: { + [classPropertyName: string]: string| + [bindingPropertyName: string, classPropertyName: string, transformFunction?: o.Expression] + }; /** * A mapping of outputs from class property names to binding property names. diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 76b88df15be..6ed21604cfb 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -327,6 +327,9 @@ export class Identifiers { static HostDirectivesFeature: o.ExternalReference = {name: 'ɵɵHostDirectivesFeature', moduleName: CORE}; + static InputTransformsFeatureFeature: + o.ExternalReference = {name: 'ɵɵInputTransformsFeature', moduleName: CORE}; + static listener: o.ExternalReference = {name: 'ɵɵlistener', moduleName: CORE}; static getInheritedFactory: o.ExternalReference = { diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index eb643d346b3..d092c3b290c 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -253,6 +253,7 @@ export interface R3InputMetadata { classPropertyName: string; bindingPropertyName: string; required: boolean; + transformFunction: o.Expression|null; } export enum R3TemplateDependencyKind { diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 519fc011c85..78519f1df9b 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -102,6 +102,8 @@ function addFeatures( const providers = meta.providers; const viewProviders = (meta as R3ComponentMetadata).viewProviders; + const inputKeys = Object.keys(meta.inputs); + if (providers || viewProviders) { const args = [providers || new o.LiteralArrayExpr([])]; if (viewProviders) { @@ -127,6 +129,12 @@ function addFeatures( features.push(o.importExpr(R3.HostDirectivesFeature).callFn([createHostDirectivesFeatureArg( meta.hostDirectives)])); } + for (const key of inputKeys) { + if (meta.inputs[key].transformFunction !== null) { + features.push(o.importExpr(R3.InputTransformsFeatureFeature)); + break; + } + } if (features.length) { definitionMap.set('features', o.literalArr(features)); } diff --git a/packages/compiler/src/render3/view/util.ts b/packages/compiler/src/render3/view/util.ts index d81cc3001bf..b8d115cbbc2 100644 --- a/packages/compiler/src/render3/view/util.ts +++ b/packages/compiler/src/render3/view/util.ts @@ -166,6 +166,7 @@ export function conditionallyCreateDirectiveBindingLiteral( map: Record, keepDeclared?: boolean): o.Expression|null { const keys = Object.getOwnPropertyNames(map); @@ -178,26 +179,37 @@ export function conditionallyCreateDirectiveBindingLiteral( let declaredName: string; let publicName: string; let minifiedName: string; - let needsDeclaredName: boolean; + let expressionValue: o.Expression; + if (typeof value === 'string') { // canonical syntax: `dirProp: publicProp` declaredName = key; minifiedName = key; publicName = value; - needsDeclaredName = false; + expressionValue = asLiteral(publicName); } else { minifiedName = key; declaredName = value.classPropertyName; publicName = value.bindingPropertyName; - needsDeclaredName = publicName !== declaredName; + + if (keepDeclared && (publicName !== declaredName || value.transformFunction != null)) { + const expressionKeys = [asLiteral(publicName), asLiteral(declaredName)]; + + if (value.transformFunction != null) { + expressionKeys.push(value.transformFunction); + } + + expressionValue = o.literalArr(expressionKeys); + } else { + expressionValue = asLiteral(publicName); + } } + return { key: minifiedName, // put quotes around keys that contain potentially unsafe characters quoted: UNSAFE_OBJECT_KEY_NAME_REGEXP.test(minifiedName), - value: (keepDeclared && needsDeclaredName) ? - o.literalArr([asLiteral(publicName), asLiteral(declaredName)]) : - asLiteral(publicName) + value: expressionValue, }; })); } diff --git a/packages/core/src/compiler/compiler_facade_interface.ts b/packages/core/src/compiler/compiler_facade_interface.ts index 128dc7fcb70..09fd34f5676 100644 --- a/packages/core/src/compiler/compiler_facade_interface.ts +++ b/packages/core/src/compiler/compiler_facade_interface.ts @@ -80,12 +80,14 @@ export type InputMap = { bindingPropertyName: string, classPropertyName: string, required: boolean, + transformFunction: InputTransformFunction, }; }; export type Provider = unknown; export type Type = Function; export type OpaqueValue = unknown; +export type InputTransformFunction = any; export enum FactoryTarget { Directive = 0, @@ -191,7 +193,11 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade { export interface R3DeclareDirectiveFacade { selector?: string; type: Type; - inputs?: {[classPropertyName: string]: string|[string, string]}; + inputs?: { + [classPropertyName: string]: string| + [bindingPropertyName: string, + classPropertyName: string, transformFunction?: InputTransformFunction] + }; outputs?: {[classPropertyName: string]: string}; host?: { attributes?: {[key: string]: OpaqueValue}; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index a89ffca61ef..3f9a7655128 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -118,6 +118,7 @@ export { ɵɵi18nPostprocess, ɵɵi18nStart, ɵɵInheritDefinitionFeature, + ɵɵInputTransformsFeature, ɵɵinjectAttribute, ɵɵInjectorDeclaration, ɵɵinvalidFactory, diff --git a/packages/core/src/render3/features/input_transforms_feature.ts b/packages/core/src/render3/features/input_transforms_feature.ts new file mode 100644 index 00000000000..5b2fa97125f --- /dev/null +++ b/packages/core/src/render3/features/input_transforms_feature.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC 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 {ComponentDef, DirectiveDef} from '../interfaces/definition'; + +// TODO(crisbeto): move input transforms runtime functionality here. +/** + * @codeGenApi + */ +export function ɵɵInputTransformsFeature(definition: DirectiveDef|ComponentDef): void {} diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index a568a2b3c33..9ccf3f1b541 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -10,6 +10,7 @@ import {ɵɵdefineComponent, ɵɵdefineDirective, ɵɵdefineNgModule, ɵɵdefine import {ɵɵCopyDefinitionFeature} from './features/copy_definition_feature'; import {ɵɵHostDirectivesFeature} from './features/host_directives_feature'; import {ɵɵInheritDefinitionFeature} from './features/inherit_definition_feature'; +import {ɵɵInputTransformsFeature} from './features/input_transforms_feature'; import {ɵɵNgOnChangesFeature} from './features/ng_onchanges_feature'; import {ɵɵProvidersFeature} from './features/providers_feature'; import {ɵɵStandaloneFeature} from './features/standalone_feature'; @@ -206,6 +207,7 @@ export { ɵɵHostDirectivesFeature, ɵɵInheritDefinitionFeature, ɵɵInjectorDeclaration, + ɵɵInputTransformsFeature, ɵɵNgModuleDeclaration, ɵɵNgOnChangesFeature, ɵɵPipeDeclaration, diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 6691c4a905c..4458f4fa79b 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -51,6 +51,7 @@ export const angularCoreEnv: {[name: string]: Function} = 'ɵɵProvidersFeature': r3.ɵɵProvidersFeature, 'ɵɵCopyDefinitionFeature': r3.ɵɵCopyDefinitionFeature, 'ɵɵInheritDefinitionFeature': r3.ɵɵInheritDefinitionFeature, + 'ɵɵInputTransformsFeature': r3.ɵɵInputTransformsFeature, 'ɵɵStandaloneFeature': r3.ɵɵStandaloneFeature, 'ɵɵnextContext': r3.ɵɵnextContext, 'ɵɵnamespaceHTML': r3.ɵɵnamespaceHTML,