diff --git a/packages/core/test/acceptance/authoring/model_inputs_spec.ts b/packages/core/test/acceptance/authoring/model_inputs_spec.ts
new file mode 100644
index 00000000000..c90765eaca4
--- /dev/null
+++ b/packages/core/test/acceptance/authoring/model_inputs_spec.ts
@@ -0,0 +1,546 @@
+/*!
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {Component, Directive, EventEmitter, Input, model, OnChanges, Output, signal, SimpleChange, SimpleChanges, ViewChild} from '@angular/core';
+import {TestBed} from '@angular/core/testing';
+
+describe('model inputs', () => {
+ it('should support two-way binding to a signal', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '
',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(2);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
+
+ it('should support two-way binding to a non-signal value', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = 1;
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value).toBe(2);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value = 3;
+ fixture.detectChanges();
+ expect(host.value).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
+
+ it('should support two-way binding a signal to a non-model input/output pair', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ @Input() value = 0;
+ @Output() valueChange = new EventEmitter();
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value()).toBe(1);
+ expect(host.dir.value).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value = 2;
+ host.dir.valueChange.emit(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(2);
+ expect(host.dir.value).toBe(2);
+
+ // Changing the value from the outside.
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value).toBe(3);
+ });
+
+ it('should support a one-way property binding to a model', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = 1;
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value).toBe(1);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value = 3;
+ fixture.detectChanges();
+ expect(host.value).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
+
+ it('should emit to the change output when the model changes', () => {
+ const emittedValues: number[] = [];
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+
+ changed(value: number) {
+ emittedValues.push(value);
+ }
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(emittedValues).toEqual([]);
+
+ host.dir.value.set(1);
+ fixture.detectChanges();
+ expect(emittedValues).toEqual([1]);
+
+ // Same value should not emit.
+ host.dir.value.set(1);
+ fixture.detectChanges();
+ expect(emittedValues).toEqual([1]);
+
+ host.dir.value.update(value => value * 5);
+ fixture.detectChanges();
+ expect(emittedValues).toEqual([1, 5]);
+ });
+
+ it('should not emit to the change event when then property binding changes', () => {
+ const emittedValues: number[] = [];
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+
+ changed(value: number) {
+ emittedValues.push(value);
+ }
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+ expect(emittedValues).toEqual([]);
+
+ host.value.set(2);
+ fixture.detectChanges();
+ expect(emittedValues).toEqual([]);
+ });
+
+ it('should support binding to the model input and output separately', () => {
+ const emittedValues: number[] = [];
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+
+ changed(value: number) {
+ emittedValues.push(value);
+ }
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(1);
+ expect(emittedValues).toEqual([]);
+
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(2);
+ expect(emittedValues).toEqual([2]);
+
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ expect(emittedValues).toEqual([2]);
+ });
+
+ it('should support two-way binding to a model with an alias', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0, {alias: 'alias'});
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(2);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
+
+ it('should support binding to an aliased model input and output separately', () => {
+ const emittedValues: number[] = [];
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0, {alias: 'alias'});
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+
+ changed(value: number) {
+ emittedValues.push(value);
+ }
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(1);
+ expect(emittedValues).toEqual([]);
+
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(2);
+ expect(emittedValues).toEqual([2]);
+
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ expect(emittedValues).toEqual([2]);
+ });
+
+ it('should throw if a required model input is accessed too early', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model.required();
+
+ constructor() {
+ this.value();
+ }
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ value = 1;
+ }
+
+ expect(() => TestBed.createComponent(App))
+ .toThrowError(/Model is required but no value is available yet/);
+ });
+
+ it('should stop emitting to the output on destroy', () => {
+ let emittedEvents = 0;
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = model(0);
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+
+ changed() {
+ emittedEvents++;
+ }
+ }
+
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+
+ const modelRef = fixture.componentInstance.dir.value;
+ expect(emittedEvents).toBe(0);
+
+ modelRef.set(1);
+ fixture.detectChanges();
+ expect(emittedEvents).toBe(1);
+
+ fixture.destroy();
+ modelRef.set(2);
+ expect(emittedEvents).toBe(1);
+ });
+
+ it('should support inherited model inputs', () => {
+ @Directive()
+ abstract class BaseDir {
+ value = model(0);
+ }
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir extends BaseDir {
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value()).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.value.set(2);
+ fixture.detectChanges();
+ expect(host.value()).toBe(2);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value.set(3);
+ fixture.detectChanges();
+ expect(host.value()).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
+
+ it('should allow a two-way-bound signal to be bound in the template', () => {
+ @Directive({
+ selector: '[dir]',
+ standalone: true,
+ host: {
+ '(click)': 'increment()',
+ }
+ })
+ class Dir {
+ value = model(0);
+
+ increment() {
+ this.value.update(previous => previous + 1);
+ }
+ }
+
+ @Component({
+ template: ' Current value: {{value()}}',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('Current value: 1');
+
+ fixture.nativeElement.querySelector('button').click();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('Current value: 2');
+
+ fixture.nativeElement.querySelector('button').click();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('Current value: 3');
+ });
+
+ it('should support ngOnChanges for two-way model bindings', () => {
+ const changes: SimpleChange[] = [];
+
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir implements OnChanges {
+ value = model(0);
+
+ ngOnChanges(allCahnges: SimpleChanges): void {
+ if (allCahnges['value']) {
+ changes.push(allCahnges['value']);
+ }
+ }
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = signal(1);
+ }
+
+ const fixture = TestBed.createComponent(App);
+ fixture.detectChanges();
+
+ expect(changes).toEqual([jasmine.objectContaining({
+ previousValue: undefined,
+ currentValue: 1,
+ firstChange: true,
+ })]);
+
+ fixture.componentInstance.value.set(2);
+ fixture.detectChanges();
+
+ expect(changes).toEqual([
+ jasmine.objectContaining({
+ previousValue: undefined,
+ currentValue: 1,
+ firstChange: true,
+ }),
+ jasmine.objectContaining({
+ previousValue: 1,
+ currentValue: 2,
+ firstChange: false,
+ }),
+ ]);
+ });
+});
diff --git a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts
index 8440f2105cf..485e5fa90a0 100644
--- a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts
+++ b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {Component, computed, Directive, effect, input} from '@angular/core';
+import {Component, computed, Directive, effect, EventEmitter, input, Output, ViewChild} from '@angular/core';
import {TestBed} from '@angular/core/testing';
describe('signal inputs', () => {
@@ -231,4 +231,43 @@ describe('signal inputs', () => {
expect(fixture.nativeElement.textContent).toBe('input:2');
});
+
+ // TODO(crisbeto): we may not want to support this combination. Will discuss with the team.
+ it('should support two-way binding to signal input and @Output decorated member', () => {
+ @Directive({selector: '[dir]', standalone: true})
+ class Dir {
+ value = input(0);
+ @Output() valueChange = new EventEmitter();
+ }
+
+ @Component({
+ template: '',
+ standalone: true,
+ imports: [Dir],
+ })
+ class App {
+ @ViewChild(Dir) dir!: Dir;
+ value = 1;
+ }
+
+ const fixture = TestBed.createComponent(App);
+ const host = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initial value.
+ expect(host.value).toBe(1);
+ expect(host.dir.value()).toBe(1);
+
+ // Changing the value from within the directive.
+ host.dir.valueChange.emit(2);
+ fixture.detectChanges();
+ expect(host.value).toBe(2);
+ expect(host.dir.value()).toBe(2);
+
+ // Changing the value from the outside.
+ host.value = 3;
+ fixture.detectChanges();
+ expect(host.value).toBe(3);
+ expect(host.dir.value()).toBe(3);
+ });
});