From bbd918acd333ef484eec3f10ea0c1498d6711490 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 30 Nov 2023 17:11:43 +0000 Subject: [PATCH] refactor(core): introduce signal `input()` function and compiler detection (#53521) This commit introduces a function for declaring inputs in components. The function is called `input`. It comes in two flavors: - `input` for optional inputs with initial values - `input.required` for required inputs Inputs are declared as class members, like with `@Input`- except that the class field will no longer hold the input value directly. Angular takes control over the input field and exposes the input value as a signal. The runtime implementation will follow in future commits. This commit simply introduces: - initial compiler detection to recognize such inputs in classes - the initial signature of `input` and `input.required`. Note: the defer size test is flawed and there is no minification- hence this commit also needs to incorporate the new dependency graph changes. PR Close #53521 --- goldens/public-api/core/index.md | 25 +-- .../directive/src/input_function.ts | 112 +++++++++++++ .../ngtsc/annotations/directive/src/shared.ts | 155 ++++++++++++++---- packages/core/BUILD.bazel | 5 +- packages/core/src/authoring.ts | 13 ++ packages/core/src/authoring/input.ts | 103 ++++++++++++ packages/core/src/authoring/input_signal.ts | 51 ++++++ packages/core/src/core.ts | 7 + .../bundling/defer/bundle.golden_symbols.json | 6 + 9 files changed, 431 insertions(+), 46 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/annotations/directive/src/input_function.ts create mode 100644 packages/core/src/authoring.ts create mode 100644 packages/core/src/authoring/input.ts create mode 100644 packages/core/src/authoring/input_signal.ts diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index fabcf9c0056..a2a37e976ea 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -873,6 +873,12 @@ export interface InputDecorator { new (arg?: string | Input): any; } +// @public +export type InputSignal = Signal & { + [ɵINPUT_SIGNAL_BRAND_READ_TYPE]: ReadT; + [ɵINPUT_SIGNAL_BRAND_WRITE_TYPE]: WriteT; +}; + // @public export function isDevMode(): boolean; @@ -1637,20 +1643,19 @@ export interface WritableSignal extends Signal { } // @public -export function ɵɵdefineInjectable(opts: { - token: unknown; - providedIn?: Type | 'root' | 'platform' | 'any' | 'environment' | null; - factory: () => T; -}): unknown; - -// @public -export function ɵɵinject(token: ProviderToken): T; +export function ɵinputFunctionForApiGuard(): InputSignal; // @public (undocumented) -export function ɵɵinject(token: ProviderToken, flags?: InjectFlags): T | null; +export function ɵinputFunctionForApiGuard(initialValue: ReadT, opts?: ɵInputOptionsWithoutTransform): InputSignal; + +// @public (undocumented) +export function ɵinputFunctionForApiGuard(initialValue: ReadT, opts: ɵInputOptionsWithTransform): InputSignal; // @public -export function ɵɵinjectAttribute(attrNameToInject: string): string | null; +export function ɵinputFunctionRequiredForApiGuard(opts?: ɵInputOptionsWithoutTransform): InputSignal; + +// @public (undocumented) +export function ɵinputFunctionRequiredForApiGuard(opts: ɵInputOptionsWithTransform): InputSignal; // (No @packageDocumentation comment for this package) diff --git a/packages/compiler-cli/src/ngtsc/annotations/directive/src/input_function.ts b/packages/compiler-cli/src/ngtsc/annotations/directive/src/input_function.ts new file mode 100644 index 00000000000..a59b6fc76a0 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/input_function.ts @@ -0,0 +1,112 @@ +/** + * @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 ts from 'typescript'; + +import {ClassMember, ReflectionHost} from '../../../reflection'; + +/** Metadata describing an input declared via the `input` function. */ +export interface InputMemberMetadata { + /** Node referring to the call expression. */ + inputCall: ts.CallExpression; + /** Node referring to the options expression, if specified. */ + optionsNode: ts.Expression|undefined; + /** Whether the input is required or not. i.e. `input.required` was used. */ + isRequired: boolean; +} + +/** + * Attempts to identify and parse an Angular input that is declared + * as a class member using the `input`/`input.required` functions. + */ +export function tryParseInputInitializerAndOptions( + member: ClassMember, reflector: ReflectionHost, + coreModule: string|undefined): InputMemberMetadata|null { + if (member.value === null || !ts.isCallExpression(member.value)) { + return null; + } + const call = member.value; + + // Extract target. Either: + // - `[input]` + // - `core.[input]` + // - `input.[required]` + // - `core.input.[required]`. + let target = extractPropertyTarget(call.expression); + if (target === null) { + return null; + } + + // Case 1: No `required`. + // TODO(signal-input-public): Clean this up. + if (target.text === 'input' || target.text === 'ɵinput') { + if (!isReferenceToInputFunction(target, coreModule, reflector)) { + return null; + } + const optionsNode: ts.Expression|undefined = call.arguments[1]; + return {inputCall: call, optionsNode, isRequired: false}; + } + + // Case 2: Using `required. + // Ensure there is a property access to `[input].required` or `[core.input].required`. + if (target.text !== 'required' || !ts.isPropertyAccessExpression(call.expression)) { + return null; + } + + const inputCall = call.expression; + target = extractPropertyTarget(inputCall.expression); + if (target === null || !isReferenceToInputFunction(target, coreModule, reflector)) { + // Ensure the call refers to the real `input` function from Angular core. + return null; + } + + const optionsNode: ts.Expression|undefined = call.arguments[0]; + + return { + inputCall: call, + optionsNode, + isRequired: true, + }; +} + +/** + * Extracts the identifier property target of a expression, supporting + * one level deep property accesses. + * + * e.g. `input.required` will return `input`. + * e.g. `input` will return `input`. + * + */ +function extractPropertyTarget(node: ts.Expression): ts.Identifier|null { + if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) { + return node.name; + } else if (ts.isIdentifier(node)) { + return node; + } + return null; +} + +/** + * Verifies that the given identifier resolves to the `input` expression from + * Angular core. + */ +function isReferenceToInputFunction( + target: ts.Identifier, coreModule: string|undefined, reflector: ReflectionHost): boolean { + const decl = reflector.getDeclarationOfIdentifier(target); + if (decl === null || !ts.isVariableDeclaration(decl.node) || decl.node.name === undefined || + !ts.isIdentifier(decl.node.name)) { + // The initializer isn't a declared, identifier named variable declaration. + return false; + } + if (coreModule !== undefined && decl.viaModule !== coreModule) { + // The initializer is matching so far, but in the wrong module. + return false; + } + // TODO(signal-input-public): Clean this up. + return decl.node.name.text === 'input' || decl.node.name.text === 'ɵinput'; +} 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 39333defca1..657a2ec504e 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts @@ -17,6 +17,8 @@ import {AmbientImport, ClassDeclaration, ClassMember, ClassMemberKind, Decorator import {CompilationMode} from '../../../transform'; import {createSourceSpan, createValueHasWrongTypeError, forwardRefResolver, getConstructorDependencies, ReferencesRegistry, toR3Reference, tryUnwrapForwardRef, unwrapConstructorDependencies, unwrapExpression, validateConstructorDependencies, wrapFunctionExpressionsInParens, wrapTypeReference,} from '../../common'; +import {tryParseInputInitializerAndOptions} from './input_function'; + const EMPTY_OBJECT: {[key: string]: string} = {}; const QUERY_TYPES = new Set([ 'ContentChild', @@ -77,9 +79,8 @@ export function extractDirectiveMetadata( // Construct the map of inputs both from the @Directive/@Component // decorator, and the decorated fields. const inputsFromMeta = parseInputsArray(clazz, directive, evaluator, reflector, refEmitter); - const inputsFromFields = parseInputFields( - clazz, filterToMembersWithDecorator(decoratedElements, 'Input', coreModule), evaluator, - reflector, refEmitter); + const inputsFromFields = + parseInputFields(clazz, members, evaluator, reflector, refEmitter, coreModule); const inputs = ClassPropertyMapping.fromMappedObject({...inputsFromMeta, ...inputsFromFields}); // And outputs. @@ -684,52 +685,138 @@ function parseInputsArray( return inputs; } -/** Parses the class members that are decorated as inputs. */ -function parseInputFields( - clazz: ClassDeclaration, inputMembers: {member: ClassMember, decorators: Decorator[]}[], - evaluator: PartialEvaluator, reflector: ReflectionHost, - refEmitter: ReferenceEmitter): Record { - const inputs = {} as Record; +/** Attempts to find a given Angular decorator on the class member. */ +function tryGetDecoratorOnMember( + member: ClassMember, decoratorName: string, coreModule: string|undefined): Decorator|null { + if (member.decorators === null) { + return null; + } - parseDecoratedFields(inputMembers, evaluator, (classPropertyName, options, decorator) => { - let bindingPropertyName: string; - let required = false; - let transform: InputTransform|null = null; + for (const decorator of member.decorators) { + if (decorator.import === null || decorator.import.name !== decoratorName) { + continue; + } + if (coreModule !== undefined && decorator.import.from !== coreModule) { + continue; + } + return decorator; + } + return null; +} - if (options === null) { - bindingPropertyName = classPropertyName; - } else if (typeof options === 'string') { - bindingPropertyName = options; - } else if (options instanceof Map) { - const aliasInConfig = options.get('alias'); - bindingPropertyName = typeof aliasInConfig === 'string' ? aliasInConfig : classPropertyName; - required = options.get('required') === true; +function tryParseInputFieldMapping( + clazz: ClassDeclaration, member: ClassMember, evaluator: PartialEvaluator, + reflector: ReflectionHost, coreModule: string|undefined, + refEmitter: ReferenceEmitter): InputMapping|null { + const classPropertyName = member.name; - if (options.has('transform')) { - const transformValue = options.get('transform'); + // Look for a decorator first. + const decorator = tryGetDecoratorOnMember(member, 'Input', coreModule); + if (decorator !== null) { + if (decorator.args !== null && decorator.args.length > 1) { + throw new FatalDiagnosticError( + ErrorCode.DECORATOR_ARITY_WRONG, decorator.node, + `@${decorator.name} can have at most one argument, got ${ + decorator.args.length} argument(s)`); + } - if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) { - throw createValueHasWrongTypeError( - decorator.node, transformValue, `Input transform must be a function`); - } + const optionsNode = + decorator.args !== null && decorator.args.length === 1 ? decorator.args[0] : undefined; + const options = optionsNode !== undefined ? evaluator.evaluate(optionsNode) : null; + const required = options instanceof Map ? options.get('required') === true : false; - transform = parseInputTransformFunction( - clazz, classPropertyName, transformValue, reflector, refEmitter); - } - } else { + // To preserve old behavior: Even though TypeScript types ensure proper options are + // passed, we sanity check for unsupported values here again. + if (options !== null && typeof options !== 'string' && !(options instanceof Map)) { throw createValueHasWrongTypeError( decorator.node, options, `@${decorator.name} decorator argument must resolve to a string or an object literal`); } - inputs[classPropertyName] = {bindingPropertyName, classPropertyName, required, transform}; - }); + let alias: string|null = null; + if (typeof options === 'string') { + alias = options; + } else if (options instanceof Map && typeof options.get('alias') === 'string') { + alias = options.get('alias') as string; + } + + const publicInputName = alias ?? classPropertyName; + + let transform: InputTransform|null = null; + if (options instanceof Map && options.has('transform')) { + const transformValue = options.get('transform'); + + if (!(transformValue instanceof DynamicValue) && !(transformValue instanceof Reference)) { + throw createValueHasWrongTypeError( + optionsNode!, transformValue, `Input transform must be a function`); + } + + transform = parseInputTransformFunction( + clazz, classPropertyName, transformValue, reflector, refEmitter); + } + + return { + classPropertyName, + bindingPropertyName: publicInputName, + transform, + required, + }; + } + + // Look for a signal input. + const signalInput = tryParseInputInitializerAndOptions(member, reflector, coreModule); + if (signalInput !== null) { + const optionsNode = signalInput.optionsNode; + const options = optionsNode !== undefined ? evaluator.evaluate(optionsNode) : null; + + let bindingPropertyName = classPropertyName; + if (options instanceof Map && typeof options.get('alias') === 'string') { + bindingPropertyName = options.get('alias') as string; + } + + return { + classPropertyName, + bindingPropertyName, + required: signalInput.isRequired, + // TODO: signal inputs- followup. + transform: null, + }; + } + + return null; +} + +/** Parses the class members that declare inputs (via decorator or initializer). */ +function parseInputFields( + clazz: ClassDeclaration, members: ClassMember[], evaluator: PartialEvaluator, + reflector: ReflectionHost, refEmitter: ReferenceEmitter, + coreModule: string|undefined): Record { + const inputs = {} as Record; + + for (const member of members) { + if (member.isStatic) { + continue; + } + + const classPropertyName = member.name; + const inputMapping = tryParseInputFieldMapping( + clazz, + member, + evaluator, + reflector, + coreModule, + refEmitter, + ); + if (inputMapping !== null) { + inputs[classPropertyName] = inputMapping; + } + } return inputs; } /** Parses the `transform` function and its type of a specific input. */ -function parseInputTransformFunction( +export function parseInputTransformFunction( clazz: ClassDeclaration, classPropertyName: string, value: DynamicValue|Reference, reflector: ReflectionHost, refEmitter: ReferenceEmitter): InputTransform { const definition = reflector.getDefinitionOfFunction(value.node); diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 5a1b843d211..5087fa76da2 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -1,7 +1,7 @@ load("@build_bazel_rules_nodejs//:index.bzl", "generated_file_test") -load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_module", "ng_package", "tsec_test") -load("//packages/common/locales:index.bzl", "generate_base_locale_file") load("@npm//@angular/build-tooling/bazel/api-gen:generate_api_docs.bzl", "generate_api_docs") +load("//packages/common/locales:index.bzl", "generate_base_locale_file") +load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_module", "ng_package", "tsec_test") package(default_visibility = ["//visibility:public"]) @@ -90,6 +90,7 @@ api_golden_test_npm_package( ], golden_dir = "angular/goldens/public-api/core", npm_package = "angular/packages/core/npm_package", + strip_export_pattern = "^ɵ(?!.*ApiGuard)", ) api_golden_test( diff --git a/packages/core/src/authoring.ts b/packages/core/src/authoring.ts new file mode 100644 index 00000000000..a393d011ae8 --- /dev/null +++ b/packages/core/src/authoring.ts @@ -0,0 +1,13 @@ +/** + * @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 + */ + +// Note: `input` is exported in `core.ts` due to: +// https://docs.google.com/document/d/1RXb1wYwsbJotO1KBgSDsAtKpduGmIHod9ADxuXcAvV4/edit?tab=t.0. + +export {InputFunction as ɵInputFunction, inputFunction as ɵinputFunctionForApiGuard, inputRequiredFunction as ɵinputFunctionRequiredForApiGuard} from './authoring/input'; +export {InputOptions as ɵInputOptions, InputOptionsWithoutTransform as ɵInputOptionsWithoutTransform, InputOptionsWithTransform as ɵInputOptionsWithTransform, InputSignal} from './authoring/input_signal'; diff --git a/packages/core/src/authoring/input.ts b/packages/core/src/authoring/input.ts new file mode 100644 index 00000000000..445ff25e250 --- /dev/null +++ b/packages/core/src/authoring/input.ts @@ -0,0 +1,103 @@ +/** + * @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 {InputOptions, InputOptionsWithoutTransform, InputOptionsWithTransform, InputSignal} from './input_signal'; + +/** + * Initializes an input with an initial value. If no explicit value + * is specified, Angular will use `undefined`. + * + * Consider using `input.required` for inputs that don't need an + * initial value. + * + * @usageNotes + * Initialize an input in your directive or component by declaring a + * class field and initializing it with the `input()` function. + * + * ```ts + * @Directive({..}) + * export class MyDir { + * firstName = input(); // string|undefined + * lastName = input.required(); // string + * age = input(0); // number + * } + * ``` + */ +export function inputFunction(): InputSignal; +export function inputFunction( + initialValue: ReadT, opts?: InputOptionsWithoutTransform): InputSignal; +export function inputFunction( + initialValue: ReadT, + opts: InputOptionsWithTransform): InputSignal; +export function inputFunction( + _initialValue?: ReadT, + _opts?: InputOptions): InputSignal { + throw new Error('TODO'); +} + +/** + * Initializes a required input. Users of your directive/component, + * need to bind to this input, otherwise they will see errors. + * * + * @usageNotes + * Initialize an input in your directive or component by declaring a + * class field and initializing it with the `input()` function. + * + * ```ts + * @Directive({..}) + * export class MyDir { + * firstName = input(); // string|undefined + * lastName = input.required(); // string + * age = input(0); // number + * } + * ``` + */ +export function inputRequiredFunction(opts?: InputOptionsWithoutTransform): + InputSignal; +export function inputRequiredFunction( + opts: InputOptionsWithTransform): InputSignal; +export function inputRequiredFunction(_opts?: InputOptions): + InputSignal { + throw new Error('TODO'); +} + +/** + * Type of the `input` function. + * + * The input function is a special function that also provides access to + * required inputs via the `.required` property. + */ +export type InputFunction = typeof inputFunction&{required: typeof inputRequiredFunction}; + +/** + * Initializes an input with an initial value. If no explicit value + * is specified, Angular will use `undefined`. + * + * Consider using `input.required` for inputs that don't need an + * initial value. + * + * @usageNotes + * Initialize an input in your directive or component by declaring a + * class field and initializing it with the `input()` function. + * + * ```ts + * @Directive({..}) + * export class MyDir { + * firstName = input(); // string|undefined + * lastName = input.required(); // string + * age = input(0); // number + * } + * ``` + */ +export const input: InputFunction = (() => { + // Note: This may be considered a side-effect, but nothing will depend on + // this assignment, unless this `input` constant export is accessed. It's a + // self-contained side effect that is local to the user facing`input` export. + (inputFunction as any).required = inputRequiredFunction; + return inputFunction as InputFunction; +})(); diff --git a/packages/core/src/authoring/input_signal.ts b/packages/core/src/authoring/input_signal.ts new file mode 100644 index 00000000000..a8835e60b2e --- /dev/null +++ b/packages/core/src/authoring/input_signal.ts @@ -0,0 +1,51 @@ +/** + * @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 {Signal} from '../render3/reactivity/api'; + +/** + * Options for signal inputs. + */ +export interface InputOptions { + /** Optional public name for the input. By default, the class field name is used. */ + alias?: string; + /** + * Optional transform that runs whenever a new value is bound. Can be used to + * transform the input value before the input is updated. + * + * The transform function can widen the type of the input. For example, consider + * an input for `disabled`. In practice, as the component author, you want to only + * deal with a boolean, but users may want to bind a string if they just use the + * attribute form to bind to the input via ``. A transform can then + * handle such string values and convert them to `boolean`. See: {@link booleanAttribute}. + */ + transform?: (v: WriteT) => ReadT; +} + +/** Signal input options without the transform option. */ +export type InputOptionsWithoutTransform = + // Note: We still keep a notion of `transform` for auto-completion. + Omit, 'transform'>&{transform?: undefined}; +/** Signal input options with the transform option required. */ +export type InputOptionsWithTransform = + Required, 'transform'>>&InputOptions; + +export const ɵINPUT_SIGNAL_BRAND_READ_TYPE = /* @__PURE__ */ Symbol(); +export const ɵINPUT_SIGNAL_BRAND_WRITE_TYPE = /* @__PURE__ */ Symbol(); + +/** + * `InputSignal` is represents a special `Signal` for a directive/component input. + * + * An input signal is similar to a non-writable signal except that it also + * carries additional type-information for transforms, and that Angular internally + * updates the signal whenever a new value is bound. + */ +export type InputSignal = Signal&{ + [ɵINPUT_SIGNAL_BRAND_READ_TYPE]: ReadT; + [ɵINPUT_SIGNAL_BRAND_WRITE_TYPE]: WriteT; +}; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 789b511212e..29835e40408 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -11,6 +11,13 @@ * @description * Entry point from which you should import all public core APIs. */ + +export * from './authoring'; +// Input is exported separately as this file is exempted from JSCompiler's +// conformance requirement for inferred const exports. +// See: https://docs.google.com/document/d/1RXb1wYwsbJotO1KBgSDsAtKpduGmIHod9ADxuXcAvV4/edit?tab=t.0 +export {input as ɵinput} from './authoring/input'; + export * from './metadata'; export * from './version'; export {TypeDecorator} from './util/decorators'; diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index a85c231e229..725e323a1dc 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1064,6 +1064,9 @@ { "name": "init_attrs_utils" }, + { + "name": "init_authoring" + }, { "name": "init_bindings" }, @@ -1466,6 +1469,9 @@ { "name": "init_innerSubscribe" }, + { + "name": "init_input" + }, { "name": "init_input_transforms_feature" },