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
This commit is contained in:
Paul Gschwendtner
2023-11-30 17:11:43 +00:00
committed by Alex Rickabaugh
parent 629343f247
commit bbd918acd3
9 changed files with 431 additions and 46 deletions
+15 -10
View File
@@ -873,6 +873,12 @@ export interface InputDecorator {
new (arg?: string | Input): any;
}
// @public
export type InputSignal<ReadT, WriteT = ReadT> = Signal<ReadT> & {
[ɵINPUT_SIGNAL_BRAND_READ_TYPE]: ReadT;
[ɵINPUT_SIGNAL_BRAND_WRITE_TYPE]: WriteT;
};
// @public
export function isDevMode(): boolean;
@@ -1637,20 +1643,19 @@ export interface WritableSignal<T> extends Signal<T> {
}
// @public
export function ɵɵdefineInjectable<T>(opts: {
token: unknown;
providedIn?: Type<any> | 'root' | 'platform' | 'any' | 'environment' | null;
factory: () => T;
}): unknown;
// @public
export function ɵɵinject<T>(token: ProviderToken<T>): T;
export function ɵinputFunctionForApiGuard<ReadT>(): InputSignal<ReadT | undefined>;
// @public (undocumented)
export function ɵɵinject<T>(token: ProviderToken<T>, flags?: InjectFlags): T | null;
export function ɵinputFunctionForApiGuard<ReadT>(initialValue: ReadT, opts?: ɵInputOptionsWithoutTransform<ReadT>): InputSignal<ReadT>;
// @public (undocumented)
export function ɵinputFunctionForApiGuard<ReadT, WriteT>(initialValue: ReadT, opts: ɵInputOptionsWithTransform<ReadT, WriteT>): InputSignal<ReadT, WriteT>;
// @public
export function ɵɵinjectAttribute(attrNameToInject: string): string | null;
export function ɵinputFunctionRequiredForApiGuard<ReadT>(opts?: ɵInputOptionsWithoutTransform<ReadT>): InputSignal<ReadT>;
// @public (undocumented)
export function ɵinputFunctionRequiredForApiGuard<ReadT, WriteT>(opts: ɵInputOptionsWithTransform<ReadT, WriteT>): InputSignal<ReadT, WriteT>;
// (No @packageDocumentation comment for this package)
@@ -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';
}
@@ -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<string, InputMapping> {
const inputs = {} as Record<string, InputMapping>;
/** 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<string, InputMapping> {
const inputs = {} as Record<string, InputMapping>;
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);
+3 -2
View File
@@ -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(
+13
View File
@@ -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';
+103
View File
@@ -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>(); // string|undefined
* lastName = input.required<string>(); // string
* age = input(0); // number
* }
* ```
*/
export function inputFunction<ReadT>(): InputSignal<ReadT|undefined>;
export function inputFunction<ReadT>(
initialValue: ReadT, opts?: InputOptionsWithoutTransform<ReadT>): InputSignal<ReadT>;
export function inputFunction<ReadT, WriteT>(
initialValue: ReadT,
opts: InputOptionsWithTransform<ReadT, WriteT>): InputSignal<ReadT, WriteT>;
export function inputFunction<ReadT, WriteT>(
_initialValue?: ReadT,
_opts?: InputOptions<ReadT, WriteT>): InputSignal<ReadT|undefined, WriteT> {
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>(); // string|undefined
* lastName = input.required<string>(); // string
* age = input(0); // number
* }
* ```
*/
export function inputRequiredFunction<ReadT>(opts?: InputOptionsWithoutTransform<ReadT>):
InputSignal<ReadT>;
export function inputRequiredFunction<ReadT, WriteT>(
opts: InputOptionsWithTransform<ReadT, WriteT>): InputSignal<ReadT, WriteT>;
export function inputRequiredFunction<ReadT, WriteT>(_opts?: InputOptions<ReadT, WriteT>):
InputSignal<ReadT, WriteT> {
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>(); // string|undefined
* lastName = input.required<string>(); // 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;
})();
@@ -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<ReadT, WriteT> {
/** 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 `<my-dir input>`. 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<ReadT> =
// Note: We still keep a notion of `transform` for auto-completion.
Omit<InputOptions<ReadT, ReadT>, 'transform'>&{transform?: undefined};
/** Signal input options with the transform option required. */
export type InputOptionsWithTransform<ReadT, WriteT> =
Required<Pick<InputOptions<ReadT, WriteT>, 'transform'>>&InputOptions<ReadT, WriteT>;
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<ReadT, WriteT = ReadT> = Signal<ReadT>&{
[ɵINPUT_SIGNAL_BRAND_READ_TYPE]: ReadT;
[ɵINPUT_SIGNAL_BRAND_WRITE_TYPE]: WriteT;
};
+7
View File
@@ -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';
@@ -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"
},