diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index 05edb9bcaf2e4..70e170d9016d8 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -235,6 +235,7 @@ export abstract class ComponentRef { abstract get instance(): C; abstract get location(): ElementRef; abstract onDestroy(callback: Function): void; + abstract setInput(name: string, value: unknown): void; } // @public diff --git a/packages/core/src/linker/component_factory.ts b/packages/core/src/linker/component_factory.ts index ffc8e0f34a3c9..0f1d77f78abe0 100644 --- a/packages/core/src/linker/component_factory.ts +++ b/packages/core/src/linker/component_factory.ts @@ -23,6 +23,16 @@ import {ViewRef} from './view_ref'; * @publicApi */ export abstract class ComponentRef { + /** + * Updates a specified input name to a new value. Using this method will properly mark for check + * component using the `OnPush` change detection strategy. It will also assure that the + * `OnChanges` lifecycle hook runs when a dynamically created component is change-detected. + * + * @param name The name of an input. + * @param value The new value of an input. + */ + abstract setInput(name: string, value: unknown): void; + /** * The host or anchor [element](guide/glossary#element) for this component instance. */ diff --git a/packages/core/src/render3/component.ts b/packages/core/src/render3/component.ts index 853723eeea857..87d7e1726e11e 100644 --- a/packages/core/src/render3/component.ts +++ b/packages/core/src/render3/component.ts @@ -15,7 +15,7 @@ import {assertDefined, assertIndexInRange} from '../util/assert'; import {diPublicInInjector, getOrCreateNodeInjectorForNode} from './di'; import {throwProviderNotFoundError} from './errors_di'; import {registerPostOrderHooks} from './hooks'; -import {addToViewTree, CLEAN_PROMISE, createLView, getOrCreateTComponentView, getOrCreateTNode, initTNodeFlags, instantiateRootComponent, invokeHostBindingsInCreationMode, markAsComponentHost, registerHostBindingOpCodes} from './instructions/shared'; +import {addToViewTree, CLEAN_PROMISE, createLView, getOrCreateTComponentView, getOrCreateTNode, initializeInputAndOutputAliases, initTNodeFlags, instantiateRootComponent, invokeHostBindingsInCreationMode, markAsComponentHost, registerHostBindingOpCodes} from './instructions/shared'; import {ComponentDef, RenderFlags} from './interfaces/definition'; import {TElementNode, TNodeType} from './interfaces/node'; import {PlayerHandler} from './interfaces/player'; diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 4ac1ba80d4b56..318ee282c58c0 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -11,7 +11,7 @@ import {Injector} from '../di/injector'; import {InjectFlags} from '../di/interface/injector'; import {ProviderToken} from '../di/provider_token'; import {EnvironmentInjector} from '../di/r3_injector'; -import {RuntimeError, RuntimeErrorCode} from '../errors'; +import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors'; import {Type} from '../interface/type'; import {ComponentFactory as viewEngine_ComponentFactory, ComponentRef as viewEngine_ComponentRef} from '../linker/component_factory'; import {ComponentFactoryResolver as viewEngine_ComponentFactoryResolver} from '../linker/component_factory_resolver'; @@ -26,16 +26,18 @@ import {assertComponentType} from './assert'; import {createRootComponent, createRootComponentView, createRootContext, LifecycleHooksFeature} from './component'; import {getComponentDef} from './definition'; import {NodeInjector} from './di'; -import {createLView, createTView, locateHostElement, renderView} from './instructions/shared'; +import {reportUnknownPropertyError} from './instructions/element_validation'; +import {createLView, createTView, initializeInputAndOutputAliases, locateHostElement, markDirtyIfOnPush, renderView, setInputsForProperty} from './instructions/shared'; import {ComponentDef} from './interfaces/definition'; -import {TContainerNode, TElementContainerNode, TElementNode, TNode} from './interfaces/node'; +import {PropertyAliasValue, TContainerNode, TElementContainerNode, TElementNode, TNode} from './interfaces/node'; import {RNode} from './interfaces/renderer_dom'; -import {HEADER_OFFSET, LView, LViewFlags, TViewType} from './interfaces/view'; +import {HEADER_OFFSET, LView, LViewFlags, TVIEW, TViewType} from './interfaces/view'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {createElementNode, writeDirectClass} from './node_manipulation'; import {extractAttrsAndClassesFromSelector, stringifyCSSSelectorList} from './node_selector_matcher'; import {enterView, leaveView} from './state'; import {setUpAttributes} from './util/attrs_utils'; +import {stringifyForError} from './util/stringify_utils'; import {getTNode} from './util/view_utils'; import {RootViewRef, ViewRef} from './view_ref'; @@ -226,7 +228,6 @@ export class ComponentFactory extends viewEngine_ComponentFactory { // Angular 5 reference: https://stackblitz.com/edit/lifecycle-hooks-vcref component = createRootComponent( componentView, this.componentDef, rootLView, rootContext, [LifecycleHooksFeature]); - renderView(rootTView, rootLView, null); } finally { leaveView(); @@ -275,6 +276,25 @@ export class ComponentRef extends viewEngine_ComponentRef { this.componentType = componentType; } + override setInput(name: string, value: unknown): void { + const inputData = this._tNode.inputs; + let dataValue: PropertyAliasValue|undefined; + if (inputData !== null && (dataValue = inputData[name])) { + const lView = this._rootLView; + setInputsForProperty(lView[TVIEW], lView, dataValue, name, value); + markDirtyIfOnPush(lView, this._tNode.index); + } else { + if (ngDevMode) { + const cmpNameForError = stringifyForError(this.componentType); + let message = + `Can't set value of the '${name}' input on the '${cmpNameForError}' component. `; + message += `Make sure that the '${ + name}' property is annotated with @Input() or a mapped @Input('${name}') exists.`; + reportUnknownPropertyError(message); + } + } + } + override get injector(): Injector { return new NodeInjector(this._tNode, this._rootLView); } diff --git a/packages/core/src/render3/instructions/element_validation.ts b/packages/core/src/render3/instructions/element_validation.ts index df39c125580b5..9c44ff5eaac54 100644 --- a/packages/core/src/render3/instructions/element_validation.ts +++ b/packages/core/src/render3/instructions/element_validation.ts @@ -207,6 +207,10 @@ export function handleUnknownPropertyError( } } + reportUnknownPropertyError(message); +} + +export function reportUnknownPropertyError(message: string) { if (shouldThrowErrorOnUnknownProperty) { throw new RuntimeError(RuntimeErrorCode.UNKNOWN_BINDING, message); } else { diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 13accae161d24..6b445286c1075 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -912,7 +912,7 @@ function generatePropertyAliases( * Initializes data structures required to work with directive inputs and outputs. * Initialization is done for all directives matched on a given TNode. */ -function initializeInputAndOutputAliases(tView: TView, tNode: TNode): void { +export function initializeInputAndOutputAliases(tView: TView, tNode: TNode): void { ngDevMode && assertFirstCreatePass(tView); const start = tNode.directiveStart; @@ -1010,7 +1010,7 @@ export function elementPropertyInternal( } /** If node is an OnPush component, marks its LView dirty. */ -function markDirtyIfOnPush(lView: LView, viewIndex: number): void { +export function markDirtyIfOnPush(lView: LView, viewIndex: number): void { ngDevMode && assertLView(lView); const childComponentLView = getComponentLViewByIndex(viewIndex, lView); if (!(childComponentLView[FLAGS] & LViewFlags.CheckAlways)) { @@ -1067,6 +1067,7 @@ export function instantiateRootComponent(tView: TView, lView: LView, def: Com directiveIndex, rootTNode.directiveStart, 'Because this is a root component the allocated expando should match the TNode component.'); configureViewWithDirective(tView, rootTNode, lView, directiveIndex, def); + initializeInputAndOutputAliases(tView, rootTNode); } const directive = getNodeInjectable(lView, tView, rootTNode.directiveStart, rootTNode as TElementNode); diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index f7b68c5803025..26a789029c2a6 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -932,6 +932,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -1085,6 +1088,9 @@ { "name": "markAsComponentHost" }, + { + "name": "markDirtyIfOnPush" + }, { "name": "maybeWrapInNotSelector" }, 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 9460a19564ae0..93c02d8d9e056 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -698,6 +698,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -965,6 +968,9 @@ { "name": "setInjectImplementation" }, + { + "name": "setInputsForProperty" + }, { "name": "setInputsFromAttrs" }, 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 dd01583a579b8..7c97e67738963 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -1028,6 +1028,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -1199,6 +1202,9 @@ { "name": "markAsComponentHost" }, + { + "name": "markDirtyIfOnPush" + }, { "name": "markDuplicates" }, 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 0f5923ce300d2..15b19078b0b9c 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 @@ -992,6 +992,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -1160,6 +1163,9 @@ { "name": "markAsComponentHost" }, + { + "name": "markDirtyIfOnPush" + }, { "name": "markDuplicates" }, 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 0d1488581d2b4..b3bd4a4d23b12 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -398,6 +398,12 @@ { "name": "forwardRef" }, + { + "name": "generateInitialInputs" + }, + { + "name": "generatePropertyAliases" + }, { "name": "getClosureSafeProperty" }, @@ -509,6 +515,9 @@ { "name": "incrementInitPhaseFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -548,6 +557,9 @@ { "name": "isImportedNgModuleProviders" }, + { + "name": "isInlineTemplate" + }, { "name": "isLContainer" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 407c89ce6d601..e6774208e8fa1 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1367,6 +1367,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "inject" }, @@ -1811,6 +1814,9 @@ { "name": "setInjectImplementation" }, + { + "name": "setInputsForProperty" + }, { "name": "setInputsFromAttrs" }, 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 07ae39ad69836..6c67f876149ca 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -473,6 +473,12 @@ { "name": "forwardRef" }, + { + "name": "generateInitialInputs" + }, + { + "name": "generatePropertyAliases" + }, { "name": "getClosureSafeProperty" }, @@ -590,6 +596,9 @@ { "name": "incrementInitPhaseFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -632,6 +641,9 @@ { "name": "isImportedNgModuleProviders" }, + { + "name": "isInlineTemplate" + }, { "name": "isLContainer" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index 28b57e340dfce..538e7efc6960b 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -866,6 +866,9 @@ { "name": "initTNodeFlags" }, + { + "name": "initializeInputAndOutputAliases" + }, { "name": "injectArgs" }, @@ -1013,6 +1016,9 @@ { "name": "markAsComponentHost" }, + { + "name": "markDirtyIfOnPush" + }, { "name": "markDuplicates" }, diff --git a/packages/core/test/render3/component_ref_spec.ts b/packages/core/test/render3/component_ref_spec.ts index 13943934e49e0..e49d36639c266 100644 --- a/packages/core/test/render3/component_ref_spec.ts +++ b/packages/core/test/render3/component_ref_spec.ts @@ -8,8 +8,9 @@ import {Renderer} from '@angular/core/src/render3/interfaces/renderer'; import {RElement} from '@angular/core/src/render3/interfaces/renderer_dom'; +import {TestBed} from '@angular/core/testing'; -import {Component, Injector, Input, NgModuleRef, Output, RendererType2, ViewEncapsulation} from '../../src/core'; +import {ChangeDetectionStrategy, Component, Injector, Input, NgModuleRef, OnChanges, Output, RendererType2, SimpleChanges, Type, ViewEncapsulation} from '../../src/core'; import {ComponentFactory} from '../../src/linker/component_factory'; import {RendererFactory2} from '../../src/render/api'; import {injectComponentFactoryResolver} from '../../src/render3/component_ref'; @@ -278,4 +279,95 @@ describe('ComponentFactory', () => { expect(hostNode.className).toEqual('HOST_COMPONENT HOST_RENDERER'); }); }); + + describe('setInput', () => { + it('should allow setting inputs on the ComponentRef', () => { + const inputChangesLog: string[] = []; + + @Component({template: `{{in}}`}) + class DynamicCmp implements OnChanges { + ngOnChanges(changes: SimpleChanges): void { + const inChange = changes['in']; + inputChangesLog.push( + `${inChange.previousValue}:${inChange.currentValue}:${inChange.firstChange}`); + } + + @Input() in : string|undefined; + } + + const fixture = TestBed.createComponent(DynamicCmp); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + expect(inputChangesLog).toEqual([]); + + fixture.componentRef.setInput('in', 'first'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('first'); + expect(inputChangesLog).toEqual(['undefined:first:true']); + + fixture.componentRef.setInput('in', 'second'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('second'); + expect(inputChangesLog).toEqual(['undefined:first:true', 'first:second:false']); + }); + + it('should allow setting mapped inputs on the ComponentRef', () => { + @Component({template: `{{in}}`}) + class DynamicCmp { + @Input('publicName') in : string|undefined; + } + + const fixture = TestBed.createComponent(DynamicCmp); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + + fixture.componentRef.setInput('publicName', 'in value'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('in value'); + + fixture.componentRef.setInput('in', 'should not change'); + fixture.detectChanges(); + // The value doesn't change, since `in` is an internal name of the input. + expect(fixture.nativeElement.textContent).toBe('in value'); + }); + + it('should log or throw error on unknown inputs', () => { + @Component({template: ``}) + class NoInputsCmp { + } + + const fixture = TestBed.createComponent(NoInputsCmp); + fixture.detectChanges(); + + spyOn(console, 'error'); + fixture.componentRef.setInput('doesNotExist', ''); + + const msgL1 = + `NG0303: Can't set value of the 'doesNotExist' input on the 'NoInputsCmp' component. `; + const msgL2 = + `Make sure that the 'doesNotExist' property is annotated with @Input() or a mapped @Input('doesNotExist') exists.`; + expect(console.error).toHaveBeenCalledWith(msgL1 + msgL2); + }); + + it('should mark components for check when setting an input on a ComponentRef', () => { + @Component({ + template: `{{in}}`, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class DynamicCmp { + @Input() in : string|undefined; + } + + const fixture = TestBed.createComponent(DynamicCmp); + + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + + fixture.componentRef.setInput('in', 'pushed'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('pushed'); + }); + }); });