diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index 4e63687cdd00..9832a0741152 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -63,6 +63,8 @@ export const enum RuntimeErrorCode { // (undocumented) INJECTOR_ALREADY_DESTROYED = 205, // (undocumented) + INVALID_BINDING_TARGET = 316, + // (undocumented) INVALID_DIFFER_INPUT = 900, // (undocumented) INVALID_EVENT_BINDING = 306, @@ -77,6 +79,8 @@ export const enum RuntimeErrorCode { // (undocumented) INVALID_MULTI_PROVIDER = -209, // (undocumented) + INVALID_SET_INPUT_CALL = 317, + // (undocumented) INVALID_SKIP_HYDRATION_HOST = -504, // (undocumented) LOOP_TRACK_DUPLICATE_KEYS = -955, @@ -109,6 +113,8 @@ export const enum RuntimeErrorCode { // (undocumented) MULTIPLE_PLATFORMS = 400, // (undocumented) + NO_BINDING_TARGET = 315, + // (undocumented) NO_SUPPORTING_DIFFER_FACTORY = 901, // (undocumented) OUTPUT_REF_DESTROYED = 953, diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 0e8496ac8a73..aeb361446ace 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -295,7 +295,7 @@ export interface ComponentDecorator { // @public @deprecated export abstract class ComponentFactory { abstract get componentType(): Type; - abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef): ComponentRef; + abstract create(injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: (Type | DirectiveWithBindings)[], bindings?: Binding[]): ComponentRef; abstract get inputs(): { propName: string; templateName: string; @@ -454,6 +454,8 @@ export function createComponent(component: Type, options: { hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }): ComponentRef; // @public @@ -994,6 +996,9 @@ export const Input: InputDecorator; // @public export const input: InputFunction; +// @public +export function inputBinding(publicName: string, value: () => unknown): Binding; + // @public (undocumented) export interface InputDecorator { (arg?: string | Input): any; @@ -1358,6 +1363,9 @@ export const Output: OutputDecorator; // @public export function output(opts?: OutputOptions): OutputEmitterRef; +// @public +export function outputBinding(eventName: string, listener: (event: T) => unknown): Binding; + // @public export interface OutputDecorator { (alias?: string): any; @@ -1980,9 +1988,11 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }): ComponentRef; // @deprecated - abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef): ComponentRef; + abstract createComponent(componentFactory: ComponentFactory, index?: number, injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, directives?: (Type | DirectiveWithBindings)[], bindings?: Binding[]): ComponentRef; abstract createEmbeddedView(templateRef: TemplateRef, context?: C, options?: { index?: number; injector?: Injector; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index e801bc009c34..2bab7ce4e521 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -112,6 +112,7 @@ export { afterNextRender, ɵFirstAvailable, } from './render3/after_render/hooks'; +export {inputBinding, outputBinding} from './render3/dynamic_bindings'; export {ApplicationConfig, mergeApplicationConfig} from './application/application_config'; export {makeStateKey, StateKey, TransferState} from './transfer_state'; export {booleanAttribute, numberAttribute} from './util/coercion'; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index f762556a0173..afeda548b34b 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -60,6 +60,9 @@ export const enum RuntimeErrorCode { HOST_DIRECTIVE_CONFLICTING_ALIAS = 312, MULTIPLE_MATCHING_PIPES = 313, UNINITIALIZED_LET_ACCESS = 314, + NO_BINDING_TARGET = 315, + INVALID_BINDING_TARGET = 316, + INVALID_SET_INPUT_CALL = 317, // Bootstrap Errors MULTIPLE_PLATFORMS = 400, diff --git a/packages/core/src/linker/component_factory.ts b/packages/core/src/linker/component_factory.ts index b3eae21cc7cb..9b4acb02971d 100644 --- a/packages/core/src/linker/component_factory.ts +++ b/packages/core/src/linker/component_factory.ts @@ -9,7 +9,8 @@ import type {ChangeDetectorRef} from '../change_detection/change_detection'; import type {Injector} from '../di/injector'; import type {EnvironmentInjector} from '../di/r3_injector'; -import {Type} from '../interface/type'; +import type {Type} from '../interface/type'; +import type {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; import type {ElementRef} from './element_ref'; import type {NgModuleRef} from './ng_module_factory'; @@ -122,5 +123,7 @@ export abstract class ComponentFactory { projectableNodes?: any[][], rootSelectorOrNode?: string | any, environmentInjector?: EnvironmentInjector | NgModuleRef, + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; } diff --git a/packages/core/src/linker/view_container_ref.ts b/packages/core/src/linker/view_container_ref.ts index 5a9f74da421c..bec830a97231 100644 --- a/packages/core/src/linker/view_container_ref.ts +++ b/packages/core/src/linker/view_container_ref.ts @@ -77,6 +77,7 @@ import {TemplateRef} from './template_ref'; import {EmbeddedViewRef, ViewRef} from './view_ref'; import {addLViewToLContainer, createLContainer, detachView} from '../render3/view/container'; import {addToEndOfViewTree} from '../render3/view/construction'; +import {Binding, DirectiveWithBindings} from '../render3/dynamic_bindings'; /** * Represents a container where one or more views can be attached to a component. @@ -225,6 +226,8 @@ export abstract class ViewContainerRef { * replace the `ngModuleRef` parameter. * * projectableNodes: list of DOM nodes that should be projected through * [``](api/core/ng-content) of the new component instance. + * * directives: Directives that should be applied to the component. + * * bindings: Bindings that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. */ @@ -236,6 +239,8 @@ export abstract class ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef; @@ -250,6 +255,8 @@ export abstract class ViewContainerRef { * [``](api/core/ng-content) of the new component instance. * @param ngModuleRef An instance of the NgModuleRef that represent an NgModule. * This information is used to retrieve corresponding NgModule injector. + * @param directives Directives that should be applied to the component. + * @param bindings Bindings that should be applied to the component. * * @returns The new `ComponentRef` which contains the component instance and the host view. * @@ -263,6 +270,8 @@ export abstract class ViewContainerRef { injector?: Injector, projectableNodes?: any[][], environmentInjector?: EnvironmentInjector | NgModuleRef, + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; /** @@ -426,6 +435,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector; projectableNodes?: Node[][]; ngModuleRef?: NgModuleRef; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef; /** @@ -439,6 +450,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef; override createComponent( componentFactoryOrType: ComponentFactory | Type, @@ -451,10 +464,14 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, injector?: Injector | undefined, projectableNodes?: any[][] | undefined, environmentInjector?: EnvironmentInjector | NgModuleRef | undefined, + directives?: (Type | DirectiveWithBindings)[], + bindings?: Binding[], ): ComponentRef { const isComponentFactory = componentFactoryOrType && !isType(componentFactoryOrType); let index: number | undefined; @@ -499,6 +516,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { ngModuleRef?: NgModuleRef; environmentInjector?: EnvironmentInjector | NgModuleRef; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }; if (ngDevMode && options.environmentInjector && options.ngModuleRef) { throwError( @@ -509,6 +528,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { injector = options.injector; projectableNodes = options.projectableNodes; environmentInjector = options.environmentInjector || options.ngModuleRef; + directives = options.directives; + bindings = options.bindings; } const componentFactory: ComponentFactory = isComponentFactory @@ -553,6 +574,8 @@ const R3ViewContainerRef = class ViewContainerRef extends VE_ViewContainerRef { projectableNodes, rNode, environmentInjector, + directives, + bindings, ); this.insertImpl( componentRef.hostView, diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index c8111097deaf..7d884988fc5b 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -13,6 +13,7 @@ import {ComponentRef} from '../linker/component_factory'; import {ComponentFactory} from './component_ref'; import {getComponentDef} from './def_getters'; +import {Binding, DirectiveWithBindings} from './dynamic_bindings'; import {assertComponentDef} from './errors'; /** @@ -73,6 +74,8 @@ import {assertComponentDef} from './errors'; * `[[element1, element2]]`: projects `element1` and `element2` into the same ``. * `[[element1, element2], [element3]]`: projects `element1` and `element2` into one ``, * and `element3` into a separate ``. + * * `directives` (optional): Directives that should be applied to the component. + * * `binding` (optional): Bindings to apply to the root component. * @returns ComponentRef instance that represents a given Component. * * @publicApi @@ -84,6 +87,8 @@ export function createComponent( hostElement?: Element; elementInjector?: Injector; projectableNodes?: Node[][]; + directives?: (Type | DirectiveWithBindings)[]; + bindings?: Binding[]; }, ): ComponentRef { ngDevMode && assertComponentDef(component); @@ -95,6 +100,8 @@ export function createComponent( options.projectableNodes, options.hostElement, options.environmentInjector, + options.directives, + options.bindings, ); } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index b5e60d328da0..aab486edd64a 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -16,7 +16,7 @@ import { import {Injector} from '../di/injector'; import {EnvironmentInjector} from '../di/r3_injector'; import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {Type} from '../interface/type'; +import {Type, Writable} from '../interface/type'; import { ComponentFactory as AbstractComponentFactory, ComponentRef as AbstractComponentRef, @@ -29,7 +29,7 @@ import {Sanitizer} from '../sanitization/sanitizer'; import {assertComponentType} from './assert'; import {attachPatchData} from './context_discovery'; -import {getComponentDef} from './def_getters'; +import {getComponentDef, getDirectiveDef} from './def_getters'; import {depsTracker} from './deps_tracker/deps_tracker'; import {NodeInjector} from './di'; import {reportUnknownPropertyError} from './instructions/element_validation'; @@ -40,7 +40,7 @@ import { locateHostElement, setAllInputsForProperty, } from './instructions/shared'; -import {ComponentDef, DirectiveDef} from './interfaces/definition'; +import {ComponentDef, ComponentTemplate, DirectiveDef, RenderFlags} from './interfaces/definition'; import {InputFlags} from './interfaces/input_flags'; import {TContainerNode, TElementContainerNode, TElementNode, TNode} from './interfaces/node'; import {RElement, RNode} from './interfaces/renderer_dom'; @@ -50,6 +50,7 @@ import { LView, LViewEnvironment, LViewFlags, + TView, TVIEW, TViewType, } from './interfaces/view'; @@ -73,6 +74,7 @@ import {getComponentLViewByIndex, getTNode} from './util/view_utils'; import {elementEndFirstCreatePass, elementStartFirstCreatePass} from './view/elements'; import {ViewRef} from './view_ref'; import {createLView, createTView, getInitialLViewFlagsFromDef} from './view/construction'; +import {BINDING, Binding, DirectiveWithBindings} from './dynamic_bindings'; export class ComponentFactoryResolver extends AbstractComponentFactoryResolver { /** @@ -231,6 +233,8 @@ export class ComponentFactory extends AbstractComponentFactory { projectableNodes?: any[][] | undefined, rootSelectorOrNode?: any, environmentInjector?: NgModuleRef | EnvironmentInjector | undefined, + directives?: (Type | DirectiveWithBindings)[], + componentBindings?: Binding[], ): AbstractComponentRef { profiler(ProfilerEvent.DynamicComponentStart); @@ -239,25 +243,7 @@ export class ComponentFactory extends AbstractComponentFactory { const cmpDef = this.componentDef; ngDevMode && verifyNotAnOrphanComponent(cmpDef); - const tAttributes = rootSelectorOrNode - ? ['ng-version', '0.0.0-PLACEHOLDER'] - : // Extract attributes and classes from the first selector only to match VE behavior. - extractAttrsAndClassesFromSelector(this.componentDef.selectors[0]); - // Create the root view. Uses empty TView and ContentTemplate. - const rootTView = createTView( - TViewType.Root, - null, - null, - 1, - 0, - null, - null, - null, - null, - [tAttributes], - null, - ); - + const rootTView = createRootTView(rootSelectorOrNode, cmpDef, componentBindings, directives); const rootViewInjector = createRootViewInjector( cmpDef, environmentInjector || this.ngModule, @@ -274,6 +260,9 @@ export class ComponentFactory extends AbstractComponentFactory { rootViewInjector, ) : createHostElement(cmpDef, hostRenderer); + const hasInputBindings = + componentBindings?.some(isInputBinding) || + directives?.some((d) => typeof d !== 'function' && d.bindings.some(isInputBinding)); const rootLView = createLView( null, @@ -289,6 +278,25 @@ export class ComponentFactory extends AbstractComponentFactory { retrieveHydrationInfo(hostElement, rootViewInjector, true /* isRootView */), ); + const directivesToApply: DirectiveDef[] = [this.componentDef]; + + if (directives) { + for (const directive of directives) { + const directiveType = typeof directive === 'function' ? directive : directive.type; + const directiveDef = getDirectiveDef(directiveType, true); + + if (ngDevMode && !directiveDef.standalone) { + throw new RuntimeError( + RuntimeErrorCode.TYPE_IS_NOT_STANDALONE, + `The ${stringifyForError(directiveType)} directive must be standalone in ` + + `order to be applied to a dynamically-created component.`, + ); + } + + directivesToApply.push(directiveDef); + } + } + rootLView[HEADER_OFFSET] = hostElement; // rootView is the parent when bootstrapping @@ -306,14 +314,14 @@ export class ComponentFactory extends AbstractComponentFactory { rootTView, rootLView, '#host', - () => [this.componentDef], + () => directivesToApply, true, 0, ); // ---- element instruction - // TODO(crisbeto): in practice `hostRNode` should always be defined, but there are some + // TODO(crisbeto): in practice `hostElement` should always be defined, but there are some // tests where the renderer is mocked out and `undefined` is returned. We should update the // tests so that this check can be removed. if (hostElement) { @@ -350,13 +358,109 @@ export class ComponentFactory extends AbstractComponentFactory { leaveView(); } - return new ComponentRef(this.componentType, rootLView); + return new ComponentRef(this.componentType, rootLView, !!hasInputBindings); } finally { setActiveConsumer(prevConsumer); } } } +function createRootTView( + rootSelectorOrNode: any, + componentDef: ComponentDef, + componentBindings: Binding[] | undefined, + directives: (Type | DirectiveWithBindings)[] | undefined, +): TView { + const tAttributes = rootSelectorOrNode + ? ['ng-version', '0.0.0-PLACEHOLDER'] + : // Extract attributes and classes from the first selector only to match VE behavior. + extractAttrsAndClassesFromSelector(componentDef.selectors[0]); + let creationBindings: Binding[] | null = null; + let updateBindings: Binding[] | null = null; + let varsToAllocate = 0; + + if (componentBindings) { + for (const binding of componentBindings) { + varsToAllocate += binding[BINDING].requiredVars; + + if (binding.create) { + (binding as Writable).target = componentDef; + (creationBindings ??= []).push(binding); + } + + if (binding.update) { + (binding as Writable).target = componentDef; + (updateBindings ??= []).push(binding); + } + } + } + + if (directives) { + for (const directive of directives) { + if (typeof directive !== 'function') { + const def: DirectiveDef = getDirectiveDef(directive.type, true); + + for (const binding of directive.bindings) { + varsToAllocate += binding[BINDING].requiredVars; + + if (binding.create) { + (binding as Writable).target = def; + (creationBindings ??= []).push(binding); + } + + if (binding.update) { + (binding as Writable).target = def; + (updateBindings ??= []).push(binding); + } + } + } + } + } + + const rootTView = createTView( + TViewType.Root, + null, + getRootTViewTemplate(creationBindings, updateBindings), + 1, + varsToAllocate, + null, + null, + null, + null, + [tAttributes], + null, + ); + + return rootTView; +} + +function getRootTViewTemplate( + creationBindings: Binding[] | null, + updateBindings: Binding[] | null, +): ComponentTemplate | null { + if (!creationBindings && !updateBindings) { + return null; + } + + return (flags) => { + if (flags & RenderFlags.Create && creationBindings) { + for (const binding of creationBindings) { + binding.create!(); + } + } + + if (flags & RenderFlags.Update && updateBindings) { + for (const binding of updateBindings) { + binding.update!(); + } + } + }; +} + +function isInputBinding(binding: Binding): boolean { + return binding[BINDING].kind === 'input'; +} + /** * Represents an instance of a Component created via a {@link ComponentFactory}. * @@ -376,7 +480,8 @@ export class ComponentRef extends AbstractComponentRef { constructor( componentType: Type, - private _rootLView: LView, + private readonly _rootLView: LView, + private readonly _hasInputBindings: boolean, ) { super(); this._tNode = getTNode(_rootLView[TVIEW], HEADER_OFFSET) as TElementNode; @@ -390,6 +495,13 @@ export class ComponentRef extends AbstractComponentRef { } override setInput(name: string, value: unknown): void { + if (this._hasInputBindings && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_SET_INPUT_CALL, + 'Cannot call `setInput` on a component that is using the `inputBinding` function.', + ); + } + const tNode = this._tNode; this.previousInputValues ??= new Map(); // Do not set the input if it is the same as the last value diff --git a/packages/core/src/render3/def_getters.ts b/packages/core/src/render3/def_getters.ts index 29095d6d384f..3b10d10d50bd 100644 --- a/packages/core/src/render3/def_getters.ts +++ b/packages/core/src/render3/def_getters.ts @@ -12,11 +12,11 @@ import {stringify} from '../util/stringify'; import {NG_COMP_DEF, NG_DIR_DEF, NG_MOD_DEF, NG_PIPE_DEF} from './fields'; import type {ComponentDef, DirectiveDef, PipeDef} from './interfaces/definition'; -export function getNgModuleDef(type: any, throwNotFound: true): NgModuleDef; +export function getNgModuleDef(type: any, throwIfNotFound: true): NgModuleDef; export function getNgModuleDef(type: any): NgModuleDef | null; -export function getNgModuleDef(type: any, throwNotFound?: boolean): NgModuleDef | null { +export function getNgModuleDef(type: any, throwIfNotFound?: boolean): NgModuleDef | null { const ngModuleDef = type[NG_MOD_DEF] || null; - if (!ngModuleDef && throwNotFound === true) { + if (!ngModuleDef && throwIfNotFound === true) { throw new Error(`Type ${stringify(type)} does not have 'ɵmod' property.`); } return ngModuleDef; @@ -32,8 +32,14 @@ export function getComponentDef(type: any): ComponentDef | null { return type[NG_COMP_DEF] || null; } -export function getDirectiveDef(type: any): DirectiveDef | null { - return type[NG_DIR_DEF] || null; +export function getDirectiveDef(type: any, throwIfNotFound: true): DirectiveDef; +export function getDirectiveDef(type: any): DirectiveDef | null; +export function getDirectiveDef(type: any, throwIfNotFound?: boolean): DirectiveDef | null { + const def = type[NG_DIR_DEF] || null; + if (!def && throwIfNotFound === true) { + throw new Error(`Type ${stringify(type)} does not have 'ɵdir' property.`); + } + return def; } export function getPipeDef(type: any): PipeDef | null { diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts new file mode 100644 index 000000000000..2db4c5374c8b --- /dev/null +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -0,0 +1,176 @@ +/*! + * @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.dev/license + */ + +import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {Type} from '../interface/type'; +import {bindingUpdated} from './bindings'; +import {listenToDirectiveOutput, wrapListener} from './instructions/listener'; +import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; +import {DirectiveDef} from './interfaces/definition'; +import {CONTEXT} from './interfaces/view'; +import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from './state'; +import {stringifyForError} from './util/stringify_utils'; + +/** Symbol used to store and retrieve metadata about a binding. */ +export const BINDING = /* @__PURE__ */ Symbol('BINDING'); + +/** + * A dynamically-defined binding targeting. + * For example, `inputBinding('value', () => 123)` creates an input binding. + */ +export interface Binding { + readonly [BINDING]: { + readonly kind: string; + readonly requiredVars: number; + }; + + /** Target to which to apply the binding. */ + readonly target?: unknown; + + /** Callback that will be invoked during creation. */ + create?(): void; + + /** Callback that will be invoked during updates. */ + update?(): void; +} + +/** + * Represents a dynamically-created directive with bindings targeting it specifically. + */ +export interface DirectiveWithBindings { + /** Directive type that should be created. */ + type: Type; + + /** Bindings that should be applied to the specific directive. */ + bindings: Binding[]; +} + +// These are constant between all the bindings so we can reuse the objects. +const INPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'input', requiredVars: 1}; +const OUTPUT_BINDING_METADATA: Binding[typeof BINDING] = {kind: 'output', requiredVars: 0}; + +/** + * Creates an input binding. + * @param publicName Public name of the input to bind to. + * @param value Callback that returns the current value for the binding. Can be either a signal or + * a plain getter function. + * + * ### Usage Example + * In this example we create an instance of the `MyButton` component and bind the value of + * the `isDisabled` signal to its `disabled` input. + * + * ``` + * const isDisabled = signal(false); + * + * createComponent(MyButton, { + * bindings: [inputBinding('disabled', isDisabled)] + * }); + * ``` + */ +export function inputBinding(publicName: string, value: () => unknown): Binding { + // Note: ideally we would use a class here, but it seems like they + // don't get tree shaken when constructed by a function like this. + const binding: Binding = { + [BINDING]: INPUT_BINDING_METADATA, + target: null, + update: () => { + const target = binding.target as DirectiveDef; + const lView = getLView(); + const bindingIndex = nextBindingIndex(); + const resolvedValue = value(); + if (bindingUpdated(lView, bindingIndex, resolvedValue)) { + const tView = getTView(); + const tNode = getSelectedTNode(); + + if (!target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Input binding to property "${publicName}" does not have a target.`, + ); + } + + const hasSet = setDirectiveInput(tNode, tView, lView, target, publicName, resolvedValue); + + if (ngDevMode) { + if (!hasSet) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `${stringifyForError(target.type)} does not have an input with a public name of "${publicName}".`, + ); + } + storePropertyBindingMetadata(tView.data, tNode, publicName, bindingIndex); + } + } + }, + }; + + return binding; +} + +/** + * Creates an output binding. + * @param eventName Public name of the output to listen to. + * @param listener Function to be called when the output emits. + * + * ### Usage example + * In this example we create an instance of the `MyCheckbox` component and listen + * to its `onChange` event. + * + * ``` + * interface CheckboxChange { + * value: string; + * } + * + * createComponent(MyCheckbox, { + * bindings: [ + * outputBinding('onChange', event => console.log(event.value)) + * ], + * }); + * ``` + */ +export function outputBinding(eventName: string, listener: (event: T) => unknown): Binding { + // Note: ideally we would use a class here, but it seems like they + // don't get tree shaken when constructed by a function like this. + const binding: Binding = { + [BINDING]: OUTPUT_BINDING_METADATA, + target: null, + create: () => { + const target = binding.target as DirectiveDef; + + if (!target && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.NO_BINDING_TARGET, + `Output binding to "${eventName}" does not have a target.`, + ); + } + + const lView = getLView<{} | null>(); + const tView = getTView(); + const tNode = getCurrentTNode()!; + const context = lView[CONTEXT]; + const wrappedListener = wrapListener(tNode, lView, context, listener); + const hasBound = listenToDirectiveOutput( + tNode, + tView, + lView, + target, + eventName, + wrappedListener, + ); + + if (!hasBound && ngDevMode) { + throw new RuntimeError( + RuntimeErrorCode.INVALID_BINDING_TARGET, + `${stringifyForError(target.type)} does not have an output with a public name of "${eventName}".`, + ); + } + }, + }; + + return binding; +} diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index e057dbc1639a..93dfeacf68fb 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -328,7 +328,7 @@ function executeListenerWithErrorHandling( * @param wrapWithPreventDefault Whether or not to prevent default behavior * (the procedural renderer does this already, so in those cases, we should skip) */ -function wrapListener( +export function wrapListener( tNode: TNode, lView: LView<{} | null>, context: {} | null, @@ -378,3 +378,80 @@ function isOutputSubscribable(value: unknown): value is SubscribableOutput>).subscribe === 'function' ); } + +/** Listens to an output on a specific directive. */ +export function listenToDirectiveOutput( + tNode: TNode, + tView: TView, + lView: LView, + target: DirectiveDef, + eventName: string, + listenerFn: (e?: any) => any, +): boolean { + const tCleanup = tView.firstCreatePass ? getOrCreateTViewCleanup(tView) : null; + const lCleanup = getOrCreateLViewCleanup(lView); + let hostIndex: number | null = null; + let hostDirectivesStart: number | null = null; + let hostDirectivesEnd: number | null = null; + let hasOutput = false; + + if (ngDevMode && !tNode.directiveToIndex?.has(target.type)) { + throw new Error(`Node does not have a directive with type ${target.type.name}`); + } + + const data = tNode.directiveToIndex!.get(target.type)!; + + if (typeof data === 'number') { + hostIndex = data; + } else { + [hostIndex, hostDirectivesStart, hostDirectivesEnd] = data; + } + + if ( + hostDirectivesStart !== null && + hostDirectivesEnd !== null && + tNode.hostDirectiveOutputs?.hasOwnProperty(eventName) + ) { + const hostDirectiveOutputs = tNode.hostDirectiveOutputs[eventName]; + + for (let i = 0; i < hostDirectiveOutputs.length; i += 2) { + const index = hostDirectiveOutputs[i] as number; + + if (index >= hostDirectivesStart && index <= hostDirectivesEnd) { + ngDevMode && assertIndexInRange(lView, index); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + index, + hostDirectiveOutputs[i + 1] as string, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } else if (index > hostDirectivesEnd) { + break; + } + } + } + + if (hostIndex !== null && target.outputs.hasOwnProperty(eventName)) { + ngDevMode && assertIndexInRange(lView, hostIndex); + hasOutput = true; + listenToOutput( + tNode, + tView, + lView, + hostIndex, + eventName, + eventName, + listenerFn, + lCleanup, + tCleanup, + ); + } + + return hasOutput; +} diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index dc1c6e4fea70..06c830af1ca0 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -672,7 +672,7 @@ export function setDirectiveInput( lView: LView, target: DirectiveDef, publicName: string, - value: string, + value: unknown, ): boolean { let hostIndex: number | null = null; let hostDirectivesStart: number | null = null; @@ -714,7 +714,7 @@ export function setDirectiveInput( } } - if (hostIndex !== null) { + if (hostIndex !== null && target.inputs.hasOwnProperty(publicName)) { ngDevMode && assertIndexInRange(lView, hostIndex); writeToDirectiveInput(target, lView[hostIndex], publicName, value); hasSet = true; diff --git a/packages/core/src/render3/view/directives.ts b/packages/core/src/render3/view/directives.ts index 177730ed5e30..eac0c7d92f84 100644 --- a/packages/core/src/render3/view/directives.ts +++ b/packages/core/src/render3/view/directives.ts @@ -94,6 +94,8 @@ export function resolveDirectives( [directiveDefs, hostDirectiveDefs, hostDirectiveRanges] = hostDirectiveResolution; } + ngDevMode && assertNoDuplicateDirectives(directiveDefs); + initializeDirectives( tView, lView, @@ -135,9 +137,12 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR let componentDef: ComponentDef | null = null; let hasHostDirectives = false; + // Having host directives is the less common scenario. Make an initial + // validation pass so we don't allocate memory unnecessarily. for (let i = 0; i < matches.length; i++) { const def = matches[i]; + // Given that we may need this further down, we can resolve it already while validating. if (i === 0 && isComponentDef(def)) { componentDef = def; } @@ -148,11 +153,12 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR } } + // If there's at least one def with host directive, we can't bail out of this function. if (!hasHostDirectives) { return null; } - let allDirectiveDefs: DirectiveDef[] | null = null; + const allDirectiveDefs: DirectiveDef[] = []; let hostDirectiveDefs: HostDirectiveDefs | null = null; let hostDirectiveRanges: HostDirectiveRanges | null = null; @@ -168,7 +174,6 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR // 4. Selector-matched dir for (const def of matches) { if (def.findHostDirectiveDefs !== null) { - allDirectiveDefs ??= []; hostDirectiveDefs ??= new Map(); hostDirectiveRanges ??= new Map(); resolveHostDirectivesForDef(def, allDirectiveDefs, hostDirectiveRanges, hostDirectiveDefs); @@ -176,18 +181,17 @@ function resolveHostDirectives(matches: DirectiveDef[]): HostDirectiveR // Component definition needs to be pushed early to maintain the correct ordering. if (def === componentDef) { - allDirectiveDefs ??= []; allDirectiveDefs.push(def); } } - if (allDirectiveDefs !== null) { - allDirectiveDefs.push(...(componentDef === null ? matches : matches.slice(1))); - ngDevMode && assertNoDuplicateDirectives(allDirectiveDefs); - return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges]; + if (componentDef === null) { + allDirectiveDefs.push(...matches); + } else { + allDirectiveDefs.push(...matches.slice(1)); } - return null; + return [allDirectiveDefs, hostDirectiveDefs, hostDirectiveRanges]; } function resolveHostDirectivesForDef( @@ -642,7 +646,7 @@ function initTNodeFlags(tNode: TNode, index: number, numberOfDirectives: number) tNode.providerIndexes = index; } -export function assertNoDuplicateDirectives(directives: DirectiveDef[]): void { +function assertNoDuplicateDirectives(directives: DirectiveDef[]): void { // The array needs at least two elements in order to have duplicates. if (directives.length < 2) { return; diff --git a/packages/core/test/acceptance/component_spec.ts b/packages/core/test/acceptance/component_spec.ts index 80bbbeca2467..470d9bf45026 100644 --- a/packages/core/test/acceptance/component_spec.ts +++ b/packages/core/test/acceptance/component_spec.ts @@ -12,13 +12,10 @@ import { Component, ComponentRef, createComponent, - createEnvironmentInjector, Directive, ElementRef, - EmbeddedViewRef, EnvironmentInjector, forwardRef, - inject, Injectable, InjectionToken, Injector, @@ -28,7 +25,6 @@ import { OnDestroy, reflectComponentType, Renderer2, - Type, ViewChild, ViewContainerRef, ViewEncapsulation, @@ -36,7 +32,6 @@ import { ɵsetDocument, ɵɵdefineComponent, } from '@angular/core'; -import {stringifyForError} from '@angular/core/src/render3/util/stringify_utils'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; @@ -798,239 +793,6 @@ describe('component', () => { expect(targetEl.innerHTML).toContain('DynamicComponent Content'); }); - describe('createComponent', () => { - it('should create an instance of a standalone component', () => { - @Component({ - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello Angular!'); - - // Verify basic change detection works. - componentRef.instance.name = 'ZoneJS'; - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello ZoneJS!'); - componentRef.destroy(); - }); - - it('should create an instance of an NgModule-based component', () => { - @Component({ - template: 'Hello {{ name }}!', - standalone: false, - }) - class NgModuleBasedComponent { - name = 'Angular'; - } - - @NgModule({ - declarations: [NgModuleBasedComponent], - }) - class AppModule {} - - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(NgModuleBasedComponent, { - hostElement, - environmentInjector, - }); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello Angular!'); - - // Verify basic change detection works. - componentRef.instance.name = 'ZoneJS'; - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.textContent).toBe('Hello ZoneJS!'); - }); - - it('should render projected content', () => { - @Component({ - template: ` - | - | - - `, - }) - class StandaloneComponent {} - - // Helper method to create a `

` element - const p = (content: string): Element => { - const element = document.createElement('p'); - element.innerHTML = content; - return element; - }; - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - const projectableNodes = [[p('1')], [p('2')], [p('3')]]; - const componentRef = createComponent(StandaloneComponent, { - hostElement, - environmentInjector, - projectableNodes, - }); - - componentRef.changeDetectorRef.detectChanges(); - expect(hostElement.innerHTML.replace(/\s*/g, '')).toBe('

1

|

2

|

3

'); - componentRef.destroy(); - }); - - it('should be able to inject tokens from EnvironmentInjector', () => { - const A = new InjectionToken('A'); - @Component({ - template: 'Token: {{ a }}', - }) - class StandaloneComponent { - a = inject(A); - } - - const hostElement = document.createElement('div'); - const parentInjector = TestBed.inject(EnvironmentInjector); - const providers = [{provide: A, useValue: 'EnvironmentInjector(A)'}]; - const environmentInjector = createEnvironmentInjector(providers, parentInjector); - const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - expect(hostElement.textContent).toBe('Token: EnvironmentInjector(A)'); - componentRef.destroy(); - }); - - it('should be able to use NodeInjector from the node hierarchy', () => { - const A = new InjectionToken('A'); - const B = new InjectionToken('B'); - @Component({ - template: '{{ a }} and {{ b }}', - }) - class ChildStandaloneComponent { - a = inject(A); - b = inject(B); - } - - @Component({ - template: 'Tokens:
', - providers: [{provide: A, useValue: 'ElementInjector(A)'}], - }) - class RootStandaloneComponent { - @ViewChild('target', {read: ElementRef}) target!: ElementRef; - constructor(private injector: Injector) {} - - createChildComponent() { - const hostElement = this.target.nativeElement; - const parentInjector = this.injector.get(EnvironmentInjector); - const providers = [ - {provide: A, useValue: 'EnvironmentInjector(A)'}, - {provide: B, useValue: 'EnvironmentInjector(B)'}, - ]; - const environmentInjector = createEnvironmentInjector(providers, parentInjector); - const childComponentRef = createComponent(ChildStandaloneComponent, { - hostElement, - elementInjector: this.injector, - environmentInjector, - }); - childComponentRef.changeDetectorRef.detectChanges(); - } - } - - const fixture = TestBed.createComponent(RootStandaloneComponent); - fixture.detectChanges(); - - fixture.componentInstance.createChildComponent(); - - const rootEl = fixture.nativeElement; - - // Token A is coming from the Element Injector, token B - from the Environment Injector. - expect(rootEl.textContent).toBe('Tokens: ElementInjector(A) and EnvironmentInjector(B)'); - }); - - it('should create a host element if none provided', () => { - const selector = 'standalone-comp'; - @Component({ - selector, - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - const hostElement = (componentRef.hostView as EmbeddedViewRef) - .rootNodes[0]; - - // A host element that matches component's selector. - expect(hostElement.tagName.toLowerCase()).toBe(selector); - - expect(hostElement.textContent).toBe('Hello Angular!'); - componentRef.destroy(); - }); - - it( - 'should fall-back to use a `div` as a host element if none provided ' + - 'and element selector does not have a tag name', - () => { - @Component({ - selector: '.some-class', - template: 'Hello {{ name }}!', - }) - class StandaloneComponent { - name = 'Angular'; - } - - const environmentInjector = TestBed.inject(EnvironmentInjector); - const componentRef = createComponent(StandaloneComponent, {environmentInjector}); - componentRef.changeDetectorRef.detectChanges(); - - const hostElement = (componentRef.hostView as EmbeddedViewRef) - .rootNodes[0]; - - // A host element has the `div` tag name, since component's selector doesn't contain - // tag name information (only a class name). - expect(hostElement.tagName.toLowerCase()).toBe('div'); - - expect(hostElement.textContent).toBe('Hello Angular!'); - componentRef.destroy(); - }, - ); - - describe('error checking', () => { - it('should throw when provided class is not a component', () => { - class NotAComponent {} - - @Directive() - class ADirective {} - - @Injectable() - class AnInjectiable {} - - const errorFor = (type: Type): string => - `NG0906: The ${stringifyForError(type)} is not an Angular component, ` + - `make sure it has the \`@Component\` decorator.`; - const hostElement = document.createElement('div'); - const environmentInjector = TestBed.inject(EnvironmentInjector); - - expect(() => - createComponent(NotAComponent, {hostElement, environmentInjector}), - ).toThrowError(errorFor(NotAComponent)); - - expect(() => createComponent(ADirective, {hostElement, environmentInjector})).toThrowError( - errorFor(ADirective), - ); - - expect(() => - createComponent(AnInjectiable, {hostElement, environmentInjector}), - ).toThrowError(errorFor(AnInjectiable)); - }); - }); - }); - describe('reflectComponentType', () => { it('should create an ComponentMirror for a standalone component', () => { function transformFn() {} diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts new file mode 100644 index 000000000000..eaa226c1d95d --- /dev/null +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -0,0 +1,1401 @@ +/*! + * @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.dev/license + */ + +import { + Component, + createComponent, + createEnvironmentInjector, + Directive, + ElementRef, + EmbeddedViewRef, + EnvironmentInjector, + ErrorHandler, + EventEmitter, + inject, + Injectable, + InjectionToken, + Injector, + Input, + inputBinding, + NgModule, + OnChanges, + OnDestroy, + Output, + outputBinding, + signal, + SimpleChange, + SimpleChanges, + Type, + ViewChild, +} from '@angular/core'; +import {stringifyForError} from '@angular/core/src/render3/util/stringify_utils'; +import {TestBed} from '@angular/core/testing'; + +describe('createComponent', () => { + it('should create an instance of a standalone component', () => { + @Component({ + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello Angular!'); + + // Verify basic change detection works. + componentRef.instance.name = 'ZoneJS'; + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello ZoneJS!'); + componentRef.destroy(); + }); + + it('should create an instance of an NgModule-based component', () => { + @Component({ + template: 'Hello {{ name }}!', + standalone: false, + }) + class NgModuleBasedComponent { + name = 'Angular'; + } + + @NgModule({ + declarations: [NgModuleBasedComponent], + }) + class AppModule {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(NgModuleBasedComponent, { + hostElement, + environmentInjector, + }); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello Angular!'); + + // Verify basic change detection works. + componentRef.instance.name = 'ZoneJS'; + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('Hello ZoneJS!'); + }); + + it('should render projected content', () => { + @Component({ + template: ` + | + | + + `, + }) + class StandaloneComponent {} + + // Helper method to create a `

` element + const p = (content: string): Element => { + const element = document.createElement('p'); + element.innerHTML = content; + return element; + }; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const projectableNodes = [[p('1')], [p('2')], [p('3')]]; + const componentRef = createComponent(StandaloneComponent, { + hostElement, + environmentInjector, + projectableNodes, + }); + + componentRef.changeDetectorRef.detectChanges(); + expect(hostElement.innerHTML.replace(/\s*/g, '')).toBe('

1

|

2

|

3

'); + componentRef.destroy(); + }); + + it('should be able to inject tokens from EnvironmentInjector', () => { + const A = new InjectionToken('A'); + @Component({ + template: 'Token: {{ a }}', + }) + class StandaloneComponent { + a = inject(A); + } + + const hostElement = document.createElement('div'); + const parentInjector = TestBed.inject(EnvironmentInjector); + const providers = [{provide: A, useValue: 'EnvironmentInjector(A)'}]; + const environmentInjector = createEnvironmentInjector(providers, parentInjector); + const componentRef = createComponent(StandaloneComponent, {hostElement, environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + expect(hostElement.textContent).toBe('Token: EnvironmentInjector(A)'); + componentRef.destroy(); + }); + + it('should be able to use NodeInjector from the node hierarchy', () => { + const A = new InjectionToken('A'); + const B = new InjectionToken('B'); + @Component({ + template: '{{ a }} and {{ b }}', + }) + class ChildStandaloneComponent { + a = inject(A); + b = inject(B); + } + + @Component({ + template: 'Tokens:
', + providers: [{provide: A, useValue: 'ElementInjector(A)'}], + }) + class RootStandaloneComponent { + @ViewChild('target', {read: ElementRef}) target!: ElementRef; + constructor(private injector: Injector) {} + + createChildComponent() { + const hostElement = this.target.nativeElement; + const parentInjector = this.injector.get(EnvironmentInjector); + const providers = [ + {provide: A, useValue: 'EnvironmentInjector(A)'}, + {provide: B, useValue: 'EnvironmentInjector(B)'}, + ]; + const environmentInjector = createEnvironmentInjector(providers, parentInjector); + const childComponentRef = createComponent(ChildStandaloneComponent, { + hostElement, + elementInjector: this.injector, + environmentInjector, + }); + childComponentRef.changeDetectorRef.detectChanges(); + } + } + + const fixture = TestBed.createComponent(RootStandaloneComponent); + fixture.detectChanges(); + + fixture.componentInstance.createChildComponent(); + + const rootEl = fixture.nativeElement; + + // Token A is coming from the Element Injector, token B - from the Environment Injector. + expect(rootEl.textContent).toBe('Tokens: ElementInjector(A) and EnvironmentInjector(B)'); + }); + + it('should create a host element if none provided', () => { + const selector = 'standalone-comp'; + @Component({ + selector, + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + const hostElement = (componentRef.hostView as EmbeddedViewRef) + .rootNodes[0]; + + // A host element that matches component's selector. + expect(hostElement.tagName.toLowerCase()).toBe(selector); + + expect(hostElement.textContent).toBe('Hello Angular!'); + componentRef.destroy(); + }); + + it( + 'should fall-back to use a `div` as a host element if none provided ' + + 'and element selector does not have a tag name', + () => { + @Component({ + selector: '.some-class', + template: 'Hello {{ name }}!', + }) + class StandaloneComponent { + name = 'Angular'; + } + + const environmentInjector = TestBed.inject(EnvironmentInjector); + const componentRef = createComponent(StandaloneComponent, {environmentInjector}); + componentRef.changeDetectorRef.detectChanges(); + + const hostElement = (componentRef.hostView as EmbeddedViewRef) + .rootNodes[0]; + + // A host element has the `div` tag name, since component's selector doesn't contain + // tag name information (only a class name). + expect(hostElement.tagName.toLowerCase()).toBe('div'); + + expect(hostElement.textContent).toBe('Hello Angular!'); + componentRef.destroy(); + }, + ); + + describe('attaching directives to root component', () => { + it('should be able to attach directives when creating a component', () => { + const logs: string[] = []; + + @Directive({ + host: { + 'class': 'class-1', + 'attr-one': 'one', + }, + }) + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({ + host: { + 'class': 'class-2', + 'attr-two': 'two', + }, + }) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + @Component({ + template: '', + host: { + 'class': 'host', + 'attr-three': 'host', + }, + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + expect(hostElement.className).toBe('host class-1 class-2'); + expect(hostElement.getAttribute('attr-one')).toBe('one'); + expect(hostElement.getAttribute('attr-two')).toBe('two'); + expect(hostElement.getAttribute('attr-three')).toBe('host'); + }); + + it('should support setting the value of a directive using setInput', () => { + let dirInstance: Dir; + + @Directive({}) + class Dir { + @Input() value: number | null = null; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(dirInstance!.value).toBe(null); + + ref.setInput('value', 1); + expect(dirInstance!.value).toBe(1); + + ref.setInput('value', 2); + expect(dirInstance!.value).toBe(2); + }); + + it('should execute host directives in the correct order', () => { + const logs: string[] = []; + + @Directive({}) + class Chain1_3 { + constructor() { + logs.push('Chain1 - level 3'); + } + } + + @Directive({ + hostDirectives: [Chain1_3], + }) + class Chain1_2 { + constructor() { + logs.push('Chain1 - level 2'); + } + } + + @Directive({ + hostDirectives: [Chain1_2], + }) + class Chain1 { + constructor() { + logs.push('Chain1 - level 1'); + } + } + + @Directive({}) + class Chain2_2 { + constructor() { + logs.push('Chain2 - level 2'); + } + } + + @Directive({ + hostDirectives: [Chain2_2], + }) + class Chain2 { + constructor() { + logs.push('Chain2 - level 1'); + } + } + + @Directive() + class Chain3_2 { + constructor() { + logs.push('Chain3 - level 2'); + } + } + + @Directive({hostDirectives: [Chain3_2]}) + class Chain3 { + constructor() { + logs.push('Chain3 - level 1'); + } + } + + @Component({ + selector: 'my-comp', + template: '', + hostDirectives: [Chain1, Chain2, Chain3], + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + @Directive() + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({}) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + expect(logs).toEqual([ + 'Chain1 - level 3', + 'Chain1 - level 2', + 'Chain1 - level 1', + 'Chain2 - level 2', + 'Chain2 - level 1', + 'Chain3 - level 2', + 'Chain3 - level 1', + 'HostComponent', + 'Dir1', + 'Dir2', + ]); + }); + + it('should destroy the attached directives when the component ref is destroyed', () => { + const logs: string[] = []; + + @Directive({}) + class Dir1 implements OnDestroy { + ngOnDestroy() { + logs.push('Dir1'); + } + } + + @Directive({}) + class Dir2 implements OnDestroy { + ngOnDestroy() { + logs.push('Dir2'); + } + } + + @Component({template: ''}) + class HostComponent implements OnDestroy { + ngOnDestroy() { + logs.push('HostComponent'); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir1, Dir2], + }); + + ref.destroy(); + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + }); + + it('should be able to inject the attached directive', () => { + let createdInstance: Dir | undefined; + let injectedInstance: Dir | undefined; + + @Directive({}) + class Dir { + constructor() { + createdInstance = this; + } + } + + @Component({template: ''}) + class HostComponent { + constructor() { + injectedInstance = inject(Dir); + } + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(createdInstance).toBeTruthy(); + expect(injectedInstance).toBeTruthy(); + expect(createdInstance).toBe(injectedInstance); + }); + + it('should write to the inputs of the attached directives using setInput', () => { + let dirInstance!: Dir; + + @Directive() + class Dir { + @Input() someInput = 0; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class HostComponent { + @Input() someInput = 0; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + + expect(dirInstance.someInput).toBe(0); + expect(ref.instance.someInput).toBe(0); + + ref.setInput('someInput', 1); + expect(dirInstance.someInput).toBe(1); + expect(ref.instance.someInput).toBe(1); + + ref.setInput('someInput', 2); + expect(dirInstance.someInput).toBe(2); + expect(ref.instance.someInput).toBe(2); + }); + + it('should throw if the same directive is attached multiple times', () => { + @Directive({}) + class Dir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir, Dir], + }); + }).toThrowError(/Directive Dir matches multiple times on the same element/); + }); + + it('should throw if a non-directive class is attached', () => { + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [NotADir], + }); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); + }); + + it('should throw if a non-directive class is attached using the DirectiveWithBinding syntax', () => { + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [ + { + type: NotADir, + bindings: [], + }, + ], + }); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); + }); + + it('should throw if a component class is attached', () => { + @Component({template: '', standalone: true}) + class NotADir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [NotADir], + }); + }).toThrowError(/Type NotADir does not have 'ɵdir' property/); + }); + + it('should throw if attached directive is not standalone', () => { + @Directive({standalone: false}) + class Dir {} + + @Component({template: ''}) + class HostComponent {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(HostComponent, { + hostElement, + environmentInjector, + directives: [Dir], + }); + }).toThrowError( + /The Dir directive must be standalone in order to be applied to a dynamically-created component/, + ); + }); + }); + + describe('root component inputs', () => { + it('should be able to bind to inputs of the root component', () => { + @Component({template: '{{one}} - {{two}} - {{other}}'}) + class RootComp { + @Input() one = ''; + @Input({alias: 'twoAlias'}) two = ''; + other = 'other'; + } + + const oneValue = signal('initial'); + let twoValue = 'initial'; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('one', oneValue), inputBinding('twoAlias', () => twoValue)], + }); + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('initial - initial - other'); + + oneValue.set('1'); + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('1 - initial - other'); + + twoValue = '1'; + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('1 - 1 - other'); + + oneValue.set('2'); + twoValue = '2'; + ref.changeDetectorRef.detectChanges(); + expect(hostElement.textContent).toBe('2 - 2 - other'); + }); + + it('should not bind root component inputs to directives', () => { + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(dirInstance.someInput).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('changed'); + expect(dirInstance.someInput).toBe(''); + }); + + it('should bind root component inputs to host directives of the root component, in addition to the component itself', () => { + let hostDirInstance!: RootHostDir; + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Directive() + class RootHostDir { + @Input() someInput = ''; + + constructor() { + hostDirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [{directive: RootHostDir, inputs: ['someInput']}], + }) + class RootComp { + @Input() someInput = ''; + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(hostDirInstance.someInput).toBe('initial'); + expect(dirInstance.someInput).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('changed'); + expect(hostDirInstance.someInput).toBe('changed'); + expect(dirInstance.someInput).toBe(''); + }); + + it('should bind to inputs of host directives of directives applied to the root component', () => { + let hostDirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input() someInput = ''; + + constructor() { + hostDirInstance = this; + } + } + + @Directive({ + hostDirectives: [ + { + directive: RootHostDir, + inputs: ['someInput: alias'], + }, + ], + }) + class RootDir {} + + @Component({template: ''}) + class RootComp {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + bindings: [inputBinding('alias', value)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(hostDirInstance.someInput).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(hostDirInstance.someInput).toBe('changed'); + }); + + it('should bind to aliased inputs of host directives of the root component', () => { + let dirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input({alias: 'someAlias'}) someInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [{directive: RootHostDir, inputs: ['someAlias: alias']}], + }) + class RootComp {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('alias', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(dirInstance.someInput).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(dirInstance.someInput).toBe('changed'); + }); + + it('should bind input to directives, but not the root component', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() someInput = ''; + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() someOtherInput = ''; + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + @Input() someOtherInput = ''; + } + + const oneValue = signal('initial'); + const twoValue = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir1, + bindings: [inputBinding('someInput', oneValue)], + }, + { + type: RootDir2, + bindings: [inputBinding('someOtherInput', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(''); + expect(ref.instance.someOtherInput).toBe(''); + expect(dir1Instance.someInput).toBe('initial'); + expect(dir2Instance.someOtherInput).toBe('initial'); + + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(''); + expect(ref.instance.someOtherInput).toBe(''); + expect(dir1Instance.someInput).toBe('one changed'); + expect(dir2Instance.someOtherInput).toBe('two changed'); + }); + + it('should invoke ngOnChanges when binding to a root component input', () => { + const changes: SimpleChange[] = []; + + @Component({template: ''}) + class RootComp implements OnChanges { + @Input() someInput = ''; + + ngOnChanges(c: SimpleChanges) { + changes.push(c['someInput']); + } + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(changes).toEqual([ + jasmine.objectContaining({ + firstChange: true, + previousValue: undefined, + currentValue: 'initial', + }), + ]); + + value.set('1'); + ref.changeDetectorRef.detectChanges(); + expect(changes).toEqual([ + jasmine.objectContaining({ + firstChange: true, + previousValue: undefined, + currentValue: 'initial', + }), + jasmine.objectContaining({ + firstChange: false, + previousValue: 'initial', + currentValue: '1', + }), + ]); + }); + + it('should transform input bound to the root component', () => { + @Component({template: ''}) + class RootComp { + @Input({transform: (value: string) => parseInt(value)}) someInput = -1; + } + + const value = signal(0); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(0); + + value.set(1); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe(1); + }); + + it('should bind different values to inputs that all have the same name', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() someInput = ''; + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() someInput = ''; + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const rootValue = signal('initial'); + const oneValue = signal('initial'); + const twoValue = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', rootValue)], + directives: [ + { + type: RootDir1, + bindings: [inputBinding('someInput', oneValue)], + }, + { + type: RootDir2, + bindings: [inputBinding('someInput', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('initial'); + expect(dir1Instance.someInput).toBe('initial'); + expect(dir2Instance.someInput).toBe('initial'); + + rootValue.set('root changed'); + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(ref.instance.someInput).toBe('root changed'); + expect(dir1Instance.someInput).toBe('one changed'); + expect(dir2Instance.someInput).toBe('two changed'); + }); + + it('should only invoke setters if the value has changed', () => { + let setterCount = 0; + + @Component({template: ''}) + class RootComp { + @Input() + set someInput(_: string) { + setterCount++; + } + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', value)], + }); + expect(setterCount).toBe(0); + + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(1); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(1); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(2); + ref.changeDetectorRef.detectChanges(); + expect(setterCount).toBe(2); + }); + + it('should throw if target does not have an input with a specific name', () => { + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + @Directive() + class RootDir {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + // `someInput` exists on `RootComp`, but not `RootDir`. + bindings: [inputBinding('someInput', value)], + }, + ], + }); + + expect(() => { + ref.changeDetectorRef.detectChanges(); + }).toThrowError(/RootDir does not have an input with a public name of "someInput"/); + }); + + it('should throw when using setInput on a component already using inputBindings', () => { + @Component({template: ''}) + class RootComp { + @Input() someInput = ''; + } + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [inputBinding('someInput', () => 'hello')], + }); + ref.changeDetectorRef.detectChanges(); + + expect(() => { + ref.setInput('someInput', 'changed'); + }).toThrowError( + /Cannot call `setInput` on a component that is using the `inputBinding` function/, + ); + }); + }); + + describe('root component outputs', () => { + it('should be able to bind to outputs of the root component', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter<{value: number}>(); + } + + const events: {value: number}[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding<{value: number}>('event', (event) => events.push(event))], + }); + ref.changeDetectorRef.detectChanges(); + expect(events).toEqual([]); + + ref.instance.event.emit({value: 0}); + expect(events).toEqual([jasmine.objectContaining({value: 0})]); + + ref.instance.event.emit({value: 1}); + expect(events).toEqual([ + jasmine.objectContaining({value: 0}), + jasmine.objectContaining({value: 1}), + ]); + }); + + it('should clean up root component output listeners', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + let count = 0; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', () => count++)], + }); + ref.changeDetectorRef.detectChanges(); + expect(count).toBe(0); + + ref.instance.event.emit(); + expect(count).toBe(1); + + ref.destroy(); + ref.instance.event.emit(); + expect(count).toBe(1); + }); + + it('should handle errors in root component listeners through the ErrorHandler', () => { + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: ErrorHandler, + useValue: { + handleError: (error: Error) => errors.push(error.message), + }, + }, + ], + }); + const errors: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [ + outputBinding('event', () => { + throw new Error('oh no'); + }), + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(errors).toEqual([]); + + ref.instance.event.emit(); + expect(errors).toEqual(['oh no']); + }); + + it('should listen to host directive outputs on the root component', () => { + let hostDirInstance!: RootHostDir; + let dirInstance!: RootDir; + + @Directive() + class RootHostDir { + @Output() myEvent = new EventEmitter(); + + constructor() { + hostDirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [ + { + directive: RootHostDir, + outputs: ['myEvent: event'], + }, + ], + }) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', (value) => logs.push(value))], + directives: [RootDir], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['component']); + + hostDirInstance.myEvent.emit('host directive'); + expect(logs).toEqual(['component', 'host directive']); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['component', 'host directive']); + }); + + it('should not listen to directive outputs with the same name as outputs on the root component', () => { + let dirInstance!: RootDir; + + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('event', (value) => logs.push(value))], + directives: [RootDir], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['component']); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['component']); + }); + + it('should not listen to root component outputs with the same name as outputs on one of the directives', () => { + let dirInstance!: RootDir; + + @Component({template: ''}) + class RootComp { + @Output() event = new EventEmitter(); + } + + @Directive() + class RootDir { + @Output() event = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + const logs: string[] = []; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + bindings: [outputBinding('event', (value) => logs.push(value))], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(logs).toEqual([]); + + dirInstance.event.emit('directive'); + expect(logs).toEqual(['directive']); + + ref.instance.event.emit('component'); + expect(logs).toEqual(['directive']); + }); + + it('should throw if root component does not have an output with the specified name', () => { + @Component({template: ''}) + class RootComp {} + + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('click', () => {})], + }); + }).toThrowError(/RootComp does not have an output with a public name of "click"/); + }); + + it('should not listen to native event when creating an output binding', () => { + @Component({template: ''}) + class RootComp { + @Output() click = new EventEmitter(); + } + + const hostElement = document.createElement('button'); + const spy = spyOn(hostElement, 'addEventListener'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [outputBinding('click', () => {})], + }); + ref.changeDetectorRef.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('error checking', () => { + it('should throw when provided class is not a component', () => { + class NotAComponent {} + + @Directive() + class ADirective {} + + @Injectable() + class AnInjectiable {} + + const errorFor = (type: Type): string => + `NG0906: The ${stringifyForError(type)} is not an Angular component, ` + + `make sure it has the \`@Component\` decorator.`; + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => createComponent(NotAComponent, {hostElement, environmentInjector})).toThrowError( + errorFor(NotAComponent), + ); + + expect(() => createComponent(ADirective, {hostElement, environmentInjector})).toThrowError( + errorFor(ADirective), + ); + + expect(() => createComponent(AnInjectiable, {hostElement, environmentInjector})).toThrowError( + errorFor(AnInjectiable), + ); + }); + }); +}); diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index a17d6f5011c7..00e0bcf1c5cb 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -23,6 +23,7 @@ import { InjectionToken, Injector, Input, + inputBinding, NgModule, NgModuleRef, NO_ERRORS_SCHEMA, @@ -35,6 +36,7 @@ import { RendererFactory2, RendererType2, Sanitizer, + signal, TemplateRef, ViewChild, ViewChildren, @@ -1790,6 +1792,137 @@ describe('ViewContainerRef', () => { ); }); + it('should support attaching directives when creating the component', () => { + const logs: string[] = []; + + @Directive({ + selector: '[dir-one]', + host: { + 'class': 'class-1', + 'attr-one': 'one', + }, + }) + class Dir1 { + constructor() { + logs.push('Dir1'); + } + } + + @Directive({ + selector: 'dir-two', + host: { + 'class': 'class-2', + 'attr-two': 'two', + }, + }) + class Dir2 { + constructor() { + logs.push('Dir2'); + } + } + + @Component({ + selector: 'host-component', + template: '', + standalone: false, + host: { + 'class': 'host', + 'attr-three': 'host', + }, + }) + class HostComponent { + constructor() { + logs.push('HostComponent'); + } + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [EmbeddedViewInsertionComp, VCRefDirective, HostComponent], + }); + const fixture = TestBed.createComponent(EmbeddedViewInsertionComp); + const vcRefDir = fixture.debugElement + .query(By.directive(VCRefDirective)) + .injector.get(VCRefDirective); + fixture.detectChanges(); + + expect(getElementHtml(fixture.nativeElement)).toEqual('

'); + + vcRefDir.vcref.createComponent(HostComponent, { + index: 0, + directives: [Dir1, Dir2], + }); + fixture.detectChanges(); + + expect(logs).toEqual(['HostComponent', 'Dir1', 'Dir2']); + expect(getElementHtml(fixture.nativeElement)).toEqual( + '

', + ); + }); + + it('should support binding to inputs of a component', () => { + let dirInstance!: Dir; + + @Directive({selector: '[dir]'}) + class Dir { + @Input() dirInput = ''; + + constructor() { + dirInstance = this; + } + } + + @Component({ + template: 'Value: {{hostInput}}', + standalone: false, + }) + class HostComponent { + @Input() hostInput = ''; + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + declarations: [EmbeddedViewInsertionComp, VCRefDirective, HostComponent], + }); + const hostValue = signal('initial'); + let dirValue = 'initial'; + const fixture = TestBed.createComponent(EmbeddedViewInsertionComp); + const vcRefDir = fixture.debugElement + .query(By.directive(VCRefDirective)) + .injector.get(VCRefDirective); + fixture.detectChanges(); + + const ref = vcRefDir.vcref.createComponent(HostComponent, { + index: 0, + bindings: [inputBinding('hostInput', hostValue)], + directives: [ + { + type: Dir, + bindings: [inputBinding('dirInput', () => dirValue)], + }, + ], + }); + fixture.detectChanges(); + + expect(ref.instance.hostInput).toBe('initial'); + expect(dirInstance.dirInput).toBe('initial'); + expect(fixture.nativeElement.textContent).toContain('Value: initial'); + + hostValue.set('host changed'); + fixture.detectChanges(); + + expect(ref.instance.hostInput).toBe('host changed'); + expect(dirInstance.dirInput).toBe('initial'); + expect(fixture.nativeElement.textContent).toContain('Value: host changed'); + + dirValue = 'dir changed'; + fixture.detectChanges(); + expect(ref.instance.hostInput).toBe('host changed'); + expect(dirInstance.dirInput).toBe('dir changed'); + expect(fixture.nativeElement.textContent).toContain('Value: host changed'); + }); + describe('`options` argument handling', () => { it('should work correctly when an empty object is provided', () => { fixture.componentInstance.viewContainerRef.createComponent(ChildA, {}); diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 61b4ad5af265..07bb86c40151 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -23,6 +23,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BROWSER_ANIMATIONS_PROVIDERS", "BROWSER_MODULE_PROVIDERS", "BaseAnimationRenderer", @@ -370,6 +371,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index dbbf9076feaa..bbfce89db393 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -26,6 +26,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_ANIMATIONS_PROVIDERS", "BROWSER_MODULE_PROVIDERS", "BROWSER_NOOP_ANIMATIONS_PROVIDERS", @@ -393,6 +394,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 3f230a261965..31904f30c409 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -8,6 +8,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", @@ -315,6 +316,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index f6c662956588..c72b0e500788 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -11,6 +11,7 @@ "AppComponent_Defer_6_DepsFn", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BLOOM_BUCKET_BITS", "BLOOM_MASK", "BROWSER_MODULE_PROVIDERS", @@ -474,6 +475,7 @@ "init_dom", "init_dom_node_manipulation", "init_dom_triggers", + "init_dynamic_bindings", "init_earlyeventcontract", "init_effect", "init_element", @@ -776,6 +778,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isNodeMatchingSelector", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 06b62bc51537..0e2179c677a0 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -12,6 +12,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BaseControlValueAccessor", "BehaviorSubject", @@ -461,6 +462,7 @@ "isForwardRef", "isFunction", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 103f0136b777..7a91ed45e15c 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -13,6 +13,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BaseControlValueAccessor", "BehaviorSubject", @@ -447,6 +448,7 @@ "isForwardRef", "isFunction", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index c1f7ddafefa6..be8e3353c4e3 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -6,6 +6,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BehaviorSubject", "BrowserDomAdapter", "CIRCULAR", @@ -48,6 +49,7 @@ "LOCALE_ID2", "NEW_LINE", "NG_COMP_DEF", + "NG_DIR_DEF", "NG_ELEMENT_ID", "NG_ENV_ID", "NG_FACTORY_DEF", @@ -197,6 +199,7 @@ "getCurrentTNode", "getCurrentTNodePlaceholderOk", "getDeclarationTNode", + "getDirectiveDef", "getFactoryDef", "getFirstLContainer", "getInitialLViewFlagsFromDef", @@ -253,6 +256,7 @@ "isDetachedByI18n", "isEnvironmentProviders", "isFunction", + "isInputBinding", "isLContainer", "isLView", "isPositive", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 53c4d483dcbd..193dd8e49449 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -7,6 +7,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BODY", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", @@ -332,6 +333,7 @@ "isFunction", "isHydrationSupportEnabled", "isInSkipHydrationBlock", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index d853c0aad8a7..2be8fa88239a 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -14,6 +14,7 @@ "ApplicationInitStatus", "ApplicationRef", "ApplyRedirects", + "BINDING", "BOOTSTRAP_DONE", "BROWSER_MODULE_PROVIDERS", "BaseRouteReuseStrategy", @@ -538,6 +539,7 @@ "isFunction", "isFunction2", "isInlineTemplate", + "isInputBinding", "isInteropObservable", "isIterable", "isLContainer", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index a4e8500cd3c9..9b25bf1d176d 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -6,6 +6,7 @@ "AnonymousSubject", "ApplicationInitStatus", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", @@ -282,6 +283,7 @@ "isDetachedByI18n", "isEnvironmentProviders", "isFunction", + "isInputBinding", "isLContainer", "isLView", "isPlatformServer", diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 5f5559875de1..b5147e9f816b 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -8,6 +8,7 @@ "ApplicationInitStatus", "ApplicationModule", "ApplicationRef", + "BINDING", "BROWSER_MODULE_PROVIDERS", "BehaviorSubject", "BrowserDomAdapter", @@ -376,6 +377,7 @@ "isEnvironmentProviders", "isFunction", "isInlineTemplate", + "isInputBinding", "isLContainer", "isLView", "isListLikeIterable",