From 64658c569f4a97457cfafb42804ab93fe8572476 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 12 Mar 2025 13:19:54 +0100 Subject: [PATCH] feat(core): add support for two-way bindings on dynamically-created components Builds on the changes from #60137 to add support for two-way bindings on dynamically-created components. Example usage: ```typescript import {createComponent, signal, twoWayBinding} from '@angular/core'; const value = signal(''); createComponent(MyCheckbox, { bindings: [ twoWayBinding('value', value), ], }); ``` In the example above the value of `MyCheckbox` and the `value` signal will be kept in sync. --- goldens/public-api/core/index.api.md | 3 + packages/core/src/core.ts | 2 +- packages/core/src/render3/component_ref.ts | 5 +- packages/core/src/render3/dynamic_bindings.ts | 48 +- .../test/acceptance/create_component_spec.ts | 589 +++++++++++++++++- 5 files changed, 642 insertions(+), 5 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index e54b68256105..0e6c3254df5a 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1835,6 +1835,9 @@ export const TRANSLATIONS: InjectionToken; // @public export const TRANSLATIONS_FORMAT: InjectionToken; +// @public +export function twoWayBinding(publicName: string, value: WritableSignal): Binding; + // @public export const Type: FunctionConstructor; diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 2bab7ce4e521..1b0e44f87d9b 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -112,7 +112,7 @@ export { afterNextRender, ɵFirstAvailable, } from './render3/after_render/hooks'; -export {inputBinding, outputBinding} from './render3/dynamic_bindings'; +export {inputBinding, outputBinding, twoWayBinding} 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/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index aab486edd64a..a37f435b4a2d 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -458,7 +458,8 @@ function getRootTViewTemplate( } function isInputBinding(binding: Binding): boolean { - return binding[BINDING].kind === 'input'; + const kind = binding[BINDING].kind; + return kind === 'input' || kind === 'twoWay'; } /** @@ -498,7 +499,7 @@ export class ComponentRef extends AbstractComponentRef { if (this._hasInputBindings && ngDevMode) { throw new RuntimeError( RuntimeErrorCode.INVALID_SET_INPUT_CALL, - 'Cannot call `setInput` on a component that is using the `inputBinding` function.', + 'Cannot call `setInput` on a component that is using the `inputBinding` or `twoWayBinding` functions.', ); } diff --git a/packages/core/src/render3/dynamic_bindings.ts b/packages/core/src/render3/dynamic_bindings.ts index 2db4c5374c8b..1f2638488caf 100644 --- a/packages/core/src/render3/dynamic_bindings.ts +++ b/packages/core/src/render3/dynamic_bindings.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +import {WritableSignal} from '../core_reactivity_export_internal'; import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {Type} from '../interface/type'; +import {Type, Writable} from '../interface/type'; +import {assertNotDefined} from '../util/assert'; import {bindingUpdated} from './bindings'; import {listenToDirectiveOutput, wrapListener} from './instructions/listener'; import {setDirectiveInput, storePropertyBindingMetadata} from './instructions/shared'; @@ -174,3 +176,47 @@ export function outputBinding(eventName: string, listener: (event: T) => unkn return binding; } + +/** + * Creates a two-way binding. + * @param eventName Public name of the two-way compatible input. + * @param value Writable signal from which to get the current value and to which to write new + * values. + * + * ### Usage example + * In this example we create an instance of the `MyCheckbox` component and bind to its `value` + * input using a two-way binding. + * + * ``` + * const checkboxValue = signal(''); + * + * createComponent(MyCheckbox, { + * bindings: [ + * twoWayBinding('value', checkboxValue), + * ], + * }); + * ``` + */ +export function twoWayBinding(publicName: string, value: WritableSignal): Binding { + const input = inputBinding(publicName, value); + const output = outputBinding(publicName + 'Change', (eventValue) => value.set(eventValue)); + + // We take advantage of inputs only having a `create` block and outputs only having an `update` + // block by passing them through directly instead of creating dedicated functions here. This + // assumption can break down if one of them starts targeting both blocks. These assertions + // are here to help us catch it if something changes in the future. + ngDevMode && assertNotDefined(input.create, 'Unexpected `create` callback in inputBinding'); + ngDevMode && assertNotDefined(output.update, 'Unexpected `update` callback in outputBinding'); + + return { + [BINDING]: { + kind: 'twoWay', + requiredVars: input[BINDING].requiredVars + output[BINDING].requiredVars, + }, + set target(target: unknown) { + (input as Writable).target = (output as Writable).target = target; + }, + create: output.create, + update: input.update, + }; +} diff --git a/packages/core/test/acceptance/create_component_spec.ts b/packages/core/test/acceptance/create_component_spec.ts index eaa226c1d95d..afa59911cf91 100644 --- a/packages/core/test/acceptance/create_component_spec.ts +++ b/packages/core/test/acceptance/create_component_spec.ts @@ -30,6 +30,7 @@ import { signal, SimpleChange, SimpleChanges, + twoWayBinding, Type, ViewChild, } from '@angular/core'; @@ -1107,7 +1108,7 @@ describe('createComponent', () => { expect(() => { ref.setInput('someInput', 'changed'); }).toThrowError( - /Cannot call `setInput` on a component that is using the `inputBinding` function/, + /Cannot call `setInput` on a component that is using the `inputBinding` or `twoWayBinding` functions/, ); }); }); @@ -1369,6 +1370,592 @@ describe('createComponent', () => { }); }); + describe('root component two-way bindings', () => { + it('should be able to use a two-way binding on the root component', () => { + @Component({template: 'Value: {{value}}'}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(hostElement.textContent).toBe('Value: initial'); + + value.set('1'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('1'); + expect(hostElement.textContent).toBe('Value: 1'); + + ref.instance.value = '2'; + ref.instance.valueChange.emit('2'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('2'); + expect(hostElement.textContent).toBe('Value: 2'); + }); + + it('should be able to two-way bind the same signal to multiple directives', () => { + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [ + { + type: RootDir, + bindings: [twoWayBinding('value', value)], + }, + ], + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(ref.instance.value).toBe('initial'); + expect(dirInstance.value).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('changed'); + expect(ref.instance.value).toBe('changed'); + expect(dirInstance.value).toBe('changed'); + + ref.instance.value = 'root changed'; + ref.instance.valueChange.emit('root changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('root changed'); + expect(ref.instance.value).toBe('root changed'); + expect(dirInstance.value).toBe('root changed'); + + dirInstance.value = 'dir changed'; + dirInstance.valueChange.emit('dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('dir changed'); + expect(ref.instance.value).toBe('dir changed'); + expect(dirInstance.value).toBe('dir changed'); + }); + + it('should not bind root component two-way bindings to directives', () => { + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(ref.instance.value).toBe('initial'); + expect(dirInstance.value).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('changed'); + expect(ref.instance.value).toBe('changed'); + expect(dirInstance.value).toBe(''); + + ref.instance.value = 'root changed'; + ref.instance.valueChange.emit('root changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('root changed'); + expect(ref.instance.value).toBe('root changed'); + expect(dirInstance.value).toBe(''); + + dirInstance.value = 'dir changed'; + dirInstance.valueChange.emit('dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('root changed'); + expect(ref.instance.value).toBe('root changed'); + expect(dirInstance.value).toBe('dir changed'); + }); + + it('should bind root component two-way bindings to host directives of the root component, in addition to the component itself', () => { + let hostDirInstance!: RootHostDir; + let dirInstance!: RootDir; + + @Directive() + class RootDir { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + @Directive() + class RootHostDir { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + hostDirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [{directive: RootHostDir, inputs: ['value'], outputs: ['valueChange']}], + }) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + directives: [RootDir], + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(ref.instance.value).toBe('initial'); + expect(hostDirInstance.value).toBe('initial'); + expect(dirInstance.value).toBe(''); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('changed'); + expect(ref.instance.value).toBe('changed'); + expect(hostDirInstance.value).toBe('changed'); + expect(dirInstance.value).toBe(''); + + hostDirInstance.value = 'host dir changed'; + hostDirInstance.valueChange.emit('host dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('host dir changed'); + expect(ref.instance.value).toBe('host dir changed'); + expect(hostDirInstance.value).toBe('host dir changed'); + expect(dirInstance.value).toBe(''); + + ref.instance.value = 'root changed'; + ref.instance.valueChange.emit('root changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('root changed'); + expect(ref.instance.value).toBe('root changed'); + expect(hostDirInstance.value).toBe('root changed'); + expect(dirInstance.value).toBe(''); + + dirInstance.value = 'dir changed'; + dirInstance.valueChange.emit('dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('root changed'); + expect(ref.instance.value).toBe('root changed'); + expect(hostDirInstance.value).toBe('root changed'); + expect(dirInstance.value).toBe('dir changed'); + }); + + it('should two-way bind to inputs of host directives of directives applied to the root component', () => { + let hostDirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + hostDirInstance = this; + } + } + + @Directive({ + hostDirectives: [ + { + directive: RootHostDir, + inputs: ['value: valueAlias'], + outputs: ['valueChange: valueAliasChange'], + }, + ], + }) + 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: [twoWayBinding('valueAlias', value)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(hostDirInstance.value).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('changed'); + expect(hostDirInstance.value).toBe('changed'); + + hostDirInstance.value = 'host dir changed'; + hostDirInstance.valueChange.emit('host dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('host dir changed'); + expect(hostDirInstance.value).toBe('host dir changed'); + }); + + it('should two-way bind to aliased inputs of host directives of the root component', () => { + let dirInstance!: RootHostDir; + + @Directive() + class RootHostDir { + @Input({alias: 'valueAlias'}) value = ''; + @Output('valueAliasChange') valueChange = new EventEmitter(); + + constructor() { + dirInstance = this; + } + } + + @Component({ + template: '', + hostDirectives: [ + { + directive: RootHostDir, + inputs: ['valueAlias: myAlias'], + outputs: ['valueAliasChange: myAliasChange'], + }, + ], + }) + class RootComp {} + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [twoWayBinding('myAlias', value)], + }); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('initial'); + expect(dirInstance.value).toBe('initial'); + + value.set('changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('changed'); + expect(dirInstance.value).toBe('changed'); + + dirInstance.value = 'host dir changed'; + dirInstance.valueChange.emit('host dir changed'); + ref.changeDetectorRef.detectChanges(); + expect(value()).toBe('host dir changed'); + expect(dirInstance.value).toBe('host dir changed'); + }); + + it('should two-way bind to directive inputs, but not inputs on the root component', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() otherValue = ''; + @Output() otherValueChange = new EventEmitter(); + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + @Input() otherValue = ''; + @Output() otherValueChange = new EventEmitter(); + } + + 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: [twoWayBinding('value', oneValue)], + }, + { + type: RootDir2, + bindings: [twoWayBinding('otherValue', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(oneValue()).toBe('initial'); + expect(twoValue()).toBe('initial'); + expect(ref.instance.value).toBe(''); + expect(ref.instance.otherValue).toBe(''); + expect(dir1Instance.value).toBe('initial'); + expect(dir2Instance.otherValue).toBe('initial'); + + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(oneValue()).toBe('one changed'); + expect(twoValue()).toBe('two changed'); + expect(ref.instance.value).toBe(''); + expect(ref.instance.otherValue).toBe(''); + expect(dir1Instance.value).toBe('one changed'); + expect(dir2Instance.otherValue).toBe('two changed'); + + ref.instance.value = 'root changed one'; + ref.instance.valueChange.emit('root changed one'); + ref.instance.otherValue = 'root changed two'; + ref.instance.otherValueChange.emit('root changed two'); + ref.changeDetectorRef.detectChanges(); + expect(oneValue()).toBe('one changed'); + expect(twoValue()).toBe('two changed'); + expect(ref.instance.value).toBe('root changed one'); + expect(ref.instance.otherValue).toBe('root changed two'); + expect(dir1Instance.value).toBe('one changed'); + expect(dir2Instance.otherValue).toBe('two changed'); + + dir1Instance.value = 'one changed again'; + dir1Instance.valueChange.emit('one changed again'); + ref.changeDetectorRef.detectChanges(); + expect(oneValue()).toBe('one changed again'); + expect(twoValue()).toBe('two changed'); + expect(ref.instance.value).toBe('root changed one'); + expect(ref.instance.otherValue).toBe('root changed two'); + expect(dir1Instance.value).toBe('one changed again'); + expect(dir2Instance.otherValue).toBe('two changed'); + + dir2Instance.otherValue = 'two changed again'; + dir2Instance.otherValueChange.emit('two changed again'); + ref.changeDetectorRef.detectChanges(); + expect(oneValue()).toBe('one changed again'); + expect(twoValue()).toBe('two changed again'); + expect(ref.instance.value).toBe('root changed one'); + expect(ref.instance.otherValue).toBe('root changed two'); + expect(dir1Instance.value).toBe('one changed again'); + expect(dir2Instance.otherValue).toBe('two changed again'); + }); + + it('should two-way bind different values to inputs that all have the same name', () => { + let dir1Instance!: RootDir1; + let dir2Instance!: RootDir2; + + @Directive() + class RootDir1 { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dir1Instance = this; + } + } + + @Directive() + class RootDir2 { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + constructor() { + dir2Instance = this; + } + } + + @Component({template: ''}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + 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: [twoWayBinding('value', rootValue)], + directives: [ + { + type: RootDir1, + bindings: [twoWayBinding('value', oneValue)], + }, + { + type: RootDir2, + bindings: [twoWayBinding('value', twoValue)], + }, + ], + }); + ref.changeDetectorRef.detectChanges(); + expect(rootValue()).toBe('initial'); + expect(oneValue()).toBe('initial'); + expect(twoValue()).toBe('initial'); + expect(ref.instance.value).toBe('initial'); + expect(dir1Instance.value).toBe('initial'); + expect(dir2Instance.value).toBe('initial'); + + rootValue.set('root changed'); + oneValue.set('one changed'); + twoValue.set('two changed'); + ref.changeDetectorRef.detectChanges(); + expect(rootValue()).toBe('root changed'); + expect(oneValue()).toBe('one changed'); + expect(twoValue()).toBe('two changed'); + expect(ref.instance.value).toBe('root changed'); + expect(dir1Instance.value).toBe('one changed'); + expect(dir2Instance.value).toBe('two changed'); + + dir1Instance.value = 'one changed again'; + dir1Instance.valueChange.emit('one changed again'); + ref.changeDetectorRef.detectChanges(); + expect(rootValue()).toBe('root changed'); + expect(oneValue()).toBe('one changed again'); + expect(twoValue()).toBe('two changed'); + expect(ref.instance.value).toBe('root changed'); + expect(dir1Instance.value).toBe('one changed again'); + expect(dir2Instance.value).toBe('two changed'); + + dir2Instance.value = 'two changed again'; + dir2Instance.valueChange.emit('two changed again'); + ref.changeDetectorRef.detectChanges(); + expect(rootValue()).toBe('root changed'); + expect(oneValue()).toBe('one changed again'); + expect(twoValue()).toBe('two changed again'); + expect(ref.instance.value).toBe('root changed'); + expect(dir1Instance.value).toBe('one changed again'); + expect(dir2Instance.value).toBe('two changed again'); + }); + + it('should throw if two-way binding target does not have an input with the specific name', () => { + @Component({template: ''}) + class RootComp { + @Output() valueChange = new EventEmitter(); + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + }).toThrowError(/RootComp does not have an input with a public name of "value"/); + }); + + it('should throw if two-way binding target does not have an output with the specific name', () => { + @Component({template: ''}) + class RootComp { + @Input() value = ''; + } + + const value = signal('initial'); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + + expect(() => { + createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [twoWayBinding('value', value)], + }); + }).toThrowError(/RootComp does not have an output with a public name of "valueChange"/); + }); + + it('should throw when using setInput on a component already using twoWayBinding', () => { + @Component({template: ''}) + class RootComp { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + } + + const value = signal(''); + const hostElement = document.createElement('div'); + const environmentInjector = TestBed.inject(EnvironmentInjector); + const ref = createComponent(RootComp, { + hostElement, + environmentInjector, + bindings: [twoWayBinding('value', value)], + }); + ref.changeDetectorRef.detectChanges(); + + expect(() => { + ref.setInput('value', 'changed'); + }).toThrowError( + /Cannot call `setInput` on a component that is using the `inputBinding` or `twoWayBinding` functions/, + ); + }); + }); + describe('error checking', () => { it('should throw when provided class is not a component', () => { class NotAComponent {}