From f6da0912285bf2ca4efefffd85661cf9f18e9fdc Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 10 May 2023 09:37:57 +0200 Subject: [PATCH] refactor(compiler): introduce compiler infrastructure for input transforms (#50225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the necessary compiler changes to support input transform functions. The compiler output has changed in the following ways: ### Directive handler The directive handler now extracts a reference to the input transform function and it resolves the type of its first parameter. It also asserts that the type can be referenced in the compiled output and that it doesn't clash with any pre-existing `ngAcceptInputType_` members. ### .d.ts In the generated declaration files the compiler now inserts an `ngAcceptInputType_` member for each input with a `transform` function. The member's type corresponds to the type of the first parameter of the function, e.g. ```typescript // foo.directive.ts @Directive() export class Foo { @Input({transform: (incomingValue: string) => parseInt(incomingValue)}) value: number; } // foo.directive.d.ts export class Foo { value: number; static ngAcceptInputType_value: string; } ``` ### Type check block If an input has `transform` function, the TCB will use the type of its first parameter for the setter type. This uses the same infrastructure as the `ngAcceptInputType_` members. ### Directive declaration The generated runtime directive declaration call now includes the `transform` function in the `inputs` map, if the input is being transformed. The function will be picked up by the runtime in the next commit to do the actual transformation. ```typescript // foo.directive.ts @Directive() export class Foo { @Input({transform: (incomingValue: string) => parseInt(incomingValue)}) value: number; } // foo.directive.js export class Foo { ɵdir = ɵɵdefineDirective({ inputs: { value: ['value', 'value', incomingValue => parseInt(incomingValue)] } }); } ``` PR Close #50225 --- goldens/public-api/compiler-cli/error_code.md | 1 + .../partial_directive_linker_1.ts | 26 +- .../src/ngtsc/annotations/common/index.ts | 1 + .../common/src/input_transforms.ts | 31 ++ .../src/ngtsc/annotations/common/src/util.ts | 18 +- .../annotations/component/src/handler.ts | 8 +- .../ngtsc/annotations/directive/BUILD.bazel | 1 + .../annotations/directive/src/handler.ts | 9 +- .../ngtsc/annotations/directive/src/shared.ts | 181 ++++++- .../src/ngtsc/annotations/src/pipe.ts | 4 +- .../src/ngtsc/diagnostics/src/error_code.ts | 7 + .../src/ngtsc/metadata/src/api.ts | 11 +- .../src/ngtsc/metadata/src/dts.ts | 19 +- .../metadata/src/host_directives_resolver.ts | 3 +- .../ngtsc/metadata/src/property_mapping.ts | 7 +- .../src/ngtsc/metadata/src/util.ts | 5 +- .../src/ngtsc/reflection/src/host.ts | 5 + .../src/ngtsc/reflection/src/typescript.ts | 4 + .../src/ngtsc/transform/src/api.ts | 2 +- .../src/ngtsc/transform/src/transform.ts | 5 + .../src/ngtsc/typecheck/api/api.ts | 2 +- .../src/ngtsc/typecheck/src/context.ts | 3 +- .../src/ngtsc/typecheck/src/environment.ts | 3 +- .../ngtsc/typecheck/src/type_check_block.ts | 39 +- .../ngtsc/typecheck/src/type_constructor.ts | 16 +- .../ngtsc/typecheck/test/diagnostics_spec.ts | 66 ++- .../typecheck/test/type_check_block_spec.ts | 33 ++ .../typecheck/test/type_constructor_spec.ts | 34 +- .../src/ngtsc/typecheck/testing/index.ts | 7 +- .../GOLDEN_PARTIAL.js | 52 ++ .../TEST_CASES.json | 17 + .../input_transform.ts | 18 + .../input_transform_definition.js | 8 + .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 463 ++++++++++++++++ .../test/ngtsc/template_typecheck_spec.ts | 511 +++++++++++++++++- .../compiler/src/compiler_facade_interface.ts | 8 +- packages/compiler/src/core.ts | 1 + packages/compiler/src/jit_compiler_facade.ts | 37 +- packages/compiler/src/render3/partial/api.ts | 5 +- .../compiler/src/render3/r3_identifiers.ts | 3 + packages/compiler/src/render3/view/api.ts | 1 + .../compiler/src/render3/view/compiler.ts | 8 + packages/compiler/src/render3/view/util.ts | 24 +- .../src/compiler/compiler_facade_interface.ts | 8 +- .../core/src/core_render3_private_export.ts | 1 + .../features/input_transforms_feature.ts | 15 + packages/core/src/render3/index.ts | 2 + packages/core/src/render3/jit/environment.ts | 1 + 48 files changed, 1616 insertions(+), 118 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/annotations/common/src/input_transforms.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/input_transform_definition.js create mode 100644 packages/core/src/render3/features/input_transforms_feature.ts 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,