Skip to content

Commit

Permalink
feat(core): expose new output() API (#54650)
Browse files Browse the repository at this point in the history
This commit exposes the new `output()` API with numerous benefits:

- Symmetrical API to `input()`, `model()` etc.
- Fixed types for `EventEmitter.emit`— current `emit` method of
  `EventEmitter` is broken and accepts `undefined` via `emit(value?: T)`
- Removal of RxJS specific concepts from outputs. error channels,
  completion channels etc. We now have a simple consistent
  interface.
- Automatic clean-up of subscribers upon directive/component destory-
  when subscribed programmatically.

```ts
@directive({..})
export class MyDir {
  nameChange = output<string>();     // OutputEmitterRef<string>
  onClick = output();                // OutputEmitterRef<void>
}
```

Note: RxJS custom observable cases will be handled in future commits via
explicit helpers from the interop.

PR Close #54650
  • Loading branch information
devversion authored and pkozlowski-opensource committed Mar 6, 2024
1 parent 9ce6277 commit c687b8f
Show file tree
Hide file tree
Showing 9 changed files with 52 additions and 38 deletions.
2 changes: 2 additions & 0 deletions goldens/public-api/core/errors.md
Expand Up @@ -105,6 +105,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
NO_SUPPORTING_DIFFER_FACTORY = 901,
// (undocumented)
OUTPUT_REF_DESTROYED = 953,
// (undocumented)
PIPE_NOT_FOUND = -302,
// (undocumented)
PLATFORM_ALREADY_DESTROYED = 404,
Expand Down
28 changes: 28 additions & 0 deletions goldens/public-api/core/index.md
Expand Up @@ -1266,13 +1266,41 @@ export interface Output {
// @public (undocumented)
export const Output: OutputDecorator;

// @public
export function output<T = void>(opts?: OutputOptions): OutputEmitterRef<T>;

// @public
export interface OutputDecorator {
(alias?: string): any;
// (undocumented)
new (alias?: string): any;
}

// @public
export class OutputEmitterRef<T> implements OutputRef<T> {
constructor();
emit(value: T): void;
// (undocumented)
subscribe(callback: (value: T) => void): OutputRefSubscription;
}

// @public
export interface OutputOptions {
// (undocumented)
alias?: string;
}

// @public
export interface OutputRef<T> {
subscribe(callback: (value: T) => void): OutputRefSubscription;
}

// @public
export interface OutputRefSubscription {
// (undocumented)
unsubscribe(): void;
}

// @public @deprecated
export const PACKAGE_ROOT_URL: InjectionToken<string>;

Expand Down
Expand Up @@ -16,15 +16,15 @@ runInEachFileSystem(() => {
const testCases: TestCase[] = [
{
id: 'basic output',
outputs: {'evt': {type: 'OutputEmitter<string>'}},
outputs: {'evt': {type: 'OutputEmitterRef<string>'}},
template: `<div dir (evt)="$event.bla">`,
expected: [
`TestComponent.html(1, 24): Property 'bla' does not exist on type 'string'.`,
],
},
{
id: 'output with void type',
outputs: {'evt': {type: 'OutputEmitter<void>'}},
outputs: {'evt': {type: 'OutputEmitterRef<void>'}},
template: `<div dir (evt)="$event.x">`,
expected: [
`TestComponent.html(1, 24): Property 'x' does not exist on type 'void'.`,
Expand All @@ -33,7 +33,7 @@ runInEachFileSystem(() => {
{
id: 'two way data binding, invalid',
inputs: {'value': {type: 'InputSignal<string>', isSignal: true}},
outputs: {'valueChange': {type: 'OutputEmitter<string>'}},
outputs: {'valueChange': {type: 'OutputEmitterRef<string>'}},
template: `<div dir [(value)]="bla">`,
component: `bla = true;`,
expected: [
Expand All @@ -43,14 +43,14 @@ runInEachFileSystem(() => {
{
id: 'two way data binding, valid',
inputs: {'value': {type: 'InputSignal<string>', isSignal: true}},
outputs: {'valueChange': {type: 'OutputEmitter<string>'}},
outputs: {'valueChange': {type: 'OutputEmitterRef<string>'}},
template: `<div dir [(value)]="bla">`,
component: `bla: string = ''`,
expected: [],
},
{
id: 'complex output object',
outputs: {'evt': {type: 'OutputEmitter<{works: boolean}>'}},
outputs: {'evt': {type: 'OutputEmitterRef<{works: boolean}>'}},
template: `<div dir (evt)="x = $event.works">`,
component: `x: never = null!`, // to raise a diagnostic to check the type.
expected: [
Expand All @@ -62,7 +62,7 @@ runInEachFileSystem(() => {
id: 'mixing decorator-based and initializer-based outputs',
outputs: {
evt1: {type: 'EventEmitter<string>'},
evt2: {type: 'OutputEmitter<string>'},
evt2: {type: 'OutputEmitterRef<string>'},
},
template: `<div dir (evt1)="x1 = $event" (evt2)="x2 = $event">`,
component: `
Expand All @@ -77,13 +77,13 @@ runInEachFileSystem(() => {
// restricted fields
{
id: 'allows access to private output',
outputs: {evt: {type: 'OutputEmitter<string>', restrictionModifier: 'private'}},
outputs: {evt: {type: 'OutputEmitterRef<string>', restrictionModifier: 'private'}},
template: `<div dir (evt)="true">`,
expected: [],
},
{
id: 'allows access to protected output',
outputs: {evt: {type: 'OutputEmitter<string>', restrictionModifier: 'protected'}},
outputs: {evt: {type: 'OutputEmitterRef<string>', restrictionModifier: 'protected'}},
template: `<div dir (evt)="true">`,
expected: [],
},
Expand Down
Expand Up @@ -71,7 +71,7 @@ export function typeCheckDiagnose(c: TestCase, compilerOptions?: ts.CompilerOpti
import {
InputSignal,
EventEmitter,
OutputEmitter,
OutputEmitterRef,
InputSignalWithTransform,
ModelSignal,
WritableSignal,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/authoring.ts
Expand Up @@ -14,5 +14,7 @@ export {InputOptions, InputOptionsWithoutTransform, InputOptionsWithTransform, I
export {ɵUnwrapDirectiveSignalInputs} from './authoring/input/input_type_checking';
export {ModelFunction} from './authoring/model/model';
export {ModelOptions, ModelSignal} from './authoring/model/model_signal';
export {output as ɵoutput, OutputEmitter as ɵOutputEmitter, OutputOptions as ɵOutputOptions} from './authoring/output';
export {output, OutputOptions} from './authoring/output/output';
export {getOutputDestroyRef as ɵgetOutputDestroyRef, OutputEmitterRef} from './authoring/output/output_emitter_ref';
export {OutputRef, OutputRefSubscription} from './authoring/output/output_ref';
export {ContentChildFunction, ViewChildFunction} from './authoring/queries';
Expand Up @@ -6,28 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {EventEmitter} from '../event_emitter';

/**
* An `OutputEmitter` is created by the `output()` function and can be
* used to emit values to consumers of your directive or component.
*
* Consumers of your directive/component can bind to the output and
* subscribe to changes via the bound event syntax. For example:
*
* ```html
* <my-comp (valueChange)="processNewValue($event)" />
* ```
*
* @developerPreview
*/
export interface OutputEmitter<T> {
emit(value: T): void;

// TODO: Consider exposing `subscribe` for dynamically created components.
/** @internal */
subscribe(listener: (v: T) => void): void;
}
import {OutputEmitterRef} from './output_emitter_ref';

/**
* Options for declaring an output.
Expand All @@ -52,13 +31,13 @@ export interface OutputOptions {
* ```ts
* @Directive({..})
* export class MyDir {
* nameChange = output<string>(); // OutputEmitter<string>
* onClick = output(); // OutputEmitter<void>
* nameChange = output<string>(); // OutputEmitterRef<string>
* onClick = output(); // OutputEmitterRef<void>
* }
* ```
*
* @developerPreview
*/
export function output<T = void>(opts?: OutputOptions): OutputEmitter<T> {
return new EventEmitter();
export function output<T = void>(opts?: OutputOptions): OutputEmitterRef<T> {
return new OutputEmitterRef<T>();
}
3 changes: 3 additions & 0 deletions packages/core/test/bundling/defer/bundle.golden_symbols.json
Expand Up @@ -1670,6 +1670,9 @@
{
"name": "init_output"
},
{
"name": "init_output_emitter_ref"
},
{
"name": "init_partial"
},
Expand Down
Expand Up @@ -1989,7 +1989,7 @@
"name": "tap"
},
{
"name": "throwError"
"name": "throwError2"
},
{
"name": "throwIfEmpty"
Expand Down
2 changes: 1 addition & 1 deletion packages/language-service/test/type_definitions_spec.ts
Expand Up @@ -106,7 +106,7 @@ describe('type definitions', () => {
project, {templateOverride: `<my-dir (name¦Changes)="doSmth()" />`});
expect(definitions!.length).toEqual(1);

assertTextSpans(definitions, ['OutputEmitter']);
assertTextSpans(definitions, ['OutputEmitterRef']);
assertFileNames(definitions, ['index.d.ts']);
});
});
Expand Down

0 comments on commit c687b8f

Please sign in to comment.