Skip to content

Commit

Permalink
Improved API for createEffect() listeners, introduced in v4.1.1.
Browse files Browse the repository at this point in the history
  • Loading branch information
e-oz committed Aug 13, 2024
1 parent ba27f42 commit 370c3de
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 45 deletions.
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
### 4.1.2
Improved API for `createEffect()` listeners, introduced in v4.1.1.

Methods of the function, returned by `createEffect()`:
```ts
export type EffectFnMethods = {
next: (fn: ((v: unknown) => void)) => void,
error: (fn: ((v: unknown) => void)) => void,
complete: (fn: (() => void)) => void,
next$: Observable<unknown>,
error$: Observable<unknown>,
};
```

Also, you can set `next` listener or an object with listeners as a second argument, when you call an effect:
```ts
class Component {
store = inject(Store);
dialog = inject(Dialog);
toasts = inject(Toasts);

changeZipCode(zipCode: string) {
this.store.changeZipCode(zipCode, () => this.dialog.close());

// or:
this.store.changeZipCode(zipCode, {
next: () => this.dialog.close(),
error: () => this.toasts.error('Error, please try again.'),
});
}
}
```

### 4.1.1
`createEffect()` now returns not just a function, but a function with methods! :)
API is experimental and might change, so it's documented only here for now.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ngx-collection",
"version": "4.1.1",
"version": "4.1.2",
"license": "MIT",
"author": {
"name": "Evgeniy OZ",
Expand Down
2 changes: 1 addition & 1 deletion projects/ngx-collection/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ngx-collection",
"version": "4.1.1",
"version": "4.1.2",
"license": "MIT",
"author": {
"name": "Evgeniy OZ",
Expand Down
123 changes: 83 additions & 40 deletions projects/ngx-collection/src/lib/create-effect.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
import { assertInInjectionContext, DestroyRef, inject, Injector, isDevMode, isSignal, type Signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { catchError, isObservable, type Observable, of, retry, type RetryConfig, Subject, type Subscription, take, tap, throwError } from 'rxjs';
import { dematerialize, isObservable, materialize, type Observable, of, retry, type RetryConfig, Subject, type Subscription, take, tap } from 'rxjs';

export type CreateEffectOptions = {
injector?: Injector,
/**
* @param retryOnError
* This params allows your effect keep running on error.
* When set to `false`, any non-caught error will terminate the effect.
* This param allows your effect keep running on error.
* When set to `false`, any non-caught error will terminate the effect and consequent calls will be ignored.
* Otherwise, generated effect will use `retry()`.
* You can pass `RetryConfig` object here to configure `retry()` operator.
*/
retryOnError?: boolean | RetryConfig,
};

export type EffectFnMethods = {
nextValue: (fn: ((v: unknown) => void)) => void,
nextError: (fn: ((v: unknown) => void)) => void,
onNextValue(): Observable<unknown>,
onNextError(): Observable<unknown>,
next: (fn: ((v: unknown) => void)) => void,
error: (fn: ((v: unknown) => void)) => void,
complete: (fn: (() => void)) => void,
next$: Observable<unknown>,
error$: Observable<unknown>,
};

export type EffectListeners = {
next?: (v: unknown) => void,
error?: (v: unknown) => void,
complete?: () => void,
};

/**
Expand All @@ -35,10 +42,12 @@ export function createEffect<
ObservableType = OriginType extends Observable<infer A> ? A : never,
ReturnType = ProvidedType | ObservableType extends void
? (
observableOrValue?: ObservableType | Observable<ObservableType> | Signal<ObservableType>
observableOrValue?: ObservableType | Observable<ObservableType> | Signal<ObservableType>,
next?: ((v: unknown) => void) | EffectListeners
) => Subscription
: (
observableOrValue: ObservableType | Observable<ObservableType> | Signal<ObservableType>
observableOrValue: ObservableType | Observable<ObservableType> | Signal<ObservableType>,
next?: ((v: unknown) => void) | EffectListeners
) => Subscription
>(generator: (origin$: OriginType) => Observable<unknown>, options?: CreateEffectOptions): ReturnType & EffectFnMethods {

Expand All @@ -52,21 +61,32 @@ export function createEffect<
const retryOnError = options?.retryOnError ?? true;
const retryConfig = (typeof options?.retryOnError === 'object' && options?.retryOnError) ? options?.retryOnError : {} as RetryConfig;

const nextValue = new Subject<unknown>()
const nextError = new Subject<unknown>()
const nextValue = new Subject<unknown>();
const nextError = new Subject<unknown>();
const complete = new Subject<void>();

const generated = generator(origin$ as OriginType).pipe(
tap((v) => {
if (nextValue.observed) {
nextValue.next(v);
materialize(),
tap((n) => {
switch (n.kind) {
case 'E':
if (nextError.observed) {
nextError.next(n.error);
}
break;
case 'C':
if (complete.observed) {
complete.next();
}
break;
default:
if (nextValue.observed) {
nextValue.next(n.value);
}
}
}),
catchError((e) => {
if (nextError.observed) {
nextError.next(e);
}
return throwError(() => e);
}));
dematerialize()
);

if (retryOnError) {
generated.pipe(
Expand All @@ -80,37 +100,60 @@ export function createEffect<
}

const effectFn = ((
observableOrValue?: ObservableType | Observable<ObservableType> | Signal<ObservableType>
observableOrValue?: ObservableType | Observable<ObservableType> | Signal<ObservableType>,
next?: ((v: unknown) => void) | EffectListeners
): Subscription => {
if (next) {
if (typeof next === 'function') {
nextValue.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(next);
} else {
if (next.next) {
nextValue.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(next.next);
}
if (next.error) {
nextError.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(next.error);
}
if (next.complete) {
complete.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(next.complete);
}
}
}

const observable$ = isObservable(observableOrValue)
? observableOrValue
: (isSignal(observableOrValue)
? toObservable(observableOrValue, { injector })
: of(observableOrValue)
);
if (retryOnError) {
return observable$.pipe(
retry(retryConfig),
takeUntilDestroyed(destroyRef)
).subscribe((value) => {
origin$.next(value as ObservableType);
});
} else {
return observable$.pipe(
takeUntilDestroyed(destroyRef)
).subscribe((value) => {
origin$.next(value as ObservableType);
});
}
}) as unknown as ReturnType & EffectFnMethods;

effectFn.nextValue = (fn: ((v: unknown) => void)) => {
return observable$.pipe(
takeUntilDestroyed(destroyRef)
).subscribe((value) => {
origin$.next(value as ObservableType);
});
}) as ReturnType & EffectFnMethods;

effectFn.next = (fn: ((v: unknown) => void)) => {
nextValue.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(fn);
};
effectFn.nextError = (fn: ((v: unknown) => void)) => {

effectFn.error = (fn: ((v: unknown) => void)) => {
nextError.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(fn);
};
effectFn.onNextValue = () => nextValue.asObservable();
effectFn.onNextError = () => nextError.asObservable();

effectFn.complete = (fn: (() => void)) => {
complete.pipe(take(1), takeUntilDestroyed(destroyRef)).subscribe(fn);
};

Object.defineProperty(effectFn, 'next$', {
get: () => nextValue.asObservable(),
configurable: false
});

Object.defineProperty(effectFn, 'error$', {
get: () => nextError.asObservable(),
configurable: false
});

return effectFn;
}
8 changes: 7 additions & 1 deletion projects/ngx-collection/src/lib/tests/create-effect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,14 @@ describe('createEffect', () => {
s.next('error');
expect(lastResult).toEqual('a');
s.next('b');
expect(lastResult).toEqual('b');
// s has error and will not accept emissions anymore.
// {retryOnError} in effect's config should only affect
// the effect's event loop, not the observable that is
// passed as a value - resubscribing to that observable
// might cause unexpected behavior.
expect(lastResult).toEqual('a');

// but the effect's event loop should still work
effect('next');
expect(lastResult).toEqual('next');
expect(lastError).toEqual(undefined);
Expand Down

0 comments on commit 370c3de

Please sign in to comment.