Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,9 @@ export const TRANSLATIONS: InjectionToken<string>;
// @public
export const TRANSLATIONS_FORMAT: InjectionToken<string>;

// @public
export function twoWayBinding(publicName: string, value: WritableSignal<unknown>): Binding;

// @public
export const Type: FunctionConstructor;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/render3/component_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down Expand Up @@ -498,7 +499,7 @@ export class ComponentRef<T> extends AbstractComponentRef<T> {
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.',
);
}

Expand Down
48 changes: 47 additions & 1 deletion packages/core/src/render3/dynamic_bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -174,3 +176,47 @@ export function outputBinding<T>(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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is it potentially worth to {@link ...} to the API that this function is supposed to be used with?

* In this example we create an instance of the `MyCheckbox` component and bind to its `value`
* input using a two-way binding.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not worth doing, but have we thought about some recommendations for type safety here? e.g. potentially users could do for public model fields

twoWayBinding('bla', mySignal as TargetComponent['modelField'])

*
* ```
* const checkboxValue = signal('');
*
* createComponent(MyCheckbox, {
* bindings: [
* twoWayBinding('value', checkboxValue),
* ],
* });
* ```
*/
export function twoWayBinding(publicName: string, value: WritableSignal<unknown>): 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) {

@crisbeto crisbeto Mar 12, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was debating whether to make this a setter or change target to be a setTarget function. I went with the former, because the only case where it being a function would be useful is two-way bindings.

(input as Writable<Binding>).target = (output as Writable<Binding>).target = target;
},
create: output.create,
update: input.update,
};
}
Loading