Skip to content

Commit

Permalink
refactor(component-store): fine-tune effect types (#2645)
Browse files Browse the repository at this point in the history
BREAKING CHANGE:

EffectReturnFn has been removed and the effect type is stricter and more predictable.

BEFORE:

If effect was const e = effect((o: Observable<string>) => ....) it was still possible to call e() without passing any strings

AFTER:

If effect was const e = effect((o: Observable<string>) => ....) its not allowed to call e() without passing any strings
  • Loading branch information
alex-okrushko authored Aug 5, 2020
1 parent 63b8e14 commit ee92912
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 32 deletions.
26 changes: 13 additions & 13 deletions modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ describe('Component Store', () => {
componentStore.state$.subscribe((state) => results.push(state));

// Update with Observable.
const subsription = updater(
const subscription = updater(
interval(10).pipe(
map((v) => ({ value: String(v) })),
take(10) // just in case
Expand All @@ -435,7 +435,7 @@ describe('Component Store', () => {
// Advance for 40 fake milliseconds and unsubscribe - should capture
// from '0' to '3'
advance(40);
subsription.unsubscribe();
subscription.unsubscribe();

// Advance for 20 more fake milliseconds, to check if anything else
// is captured
Expand Down Expand Up @@ -468,7 +468,7 @@ describe('Component Store', () => {
componentStore.state$.subscribe((state) => results.push(state));

// Update with Observable.
const subsription = updater(
const subscription = updater(
interval(10).pipe(
map((v) => ({ value: 'a' + v })),
take(10) // just in case
Expand All @@ -486,7 +486,7 @@ describe('Component Store', () => {
// Advance for 40 fake milliseconds and unsubscribe - should capture
// from '0' to '3'
advance(40);
subsription.unsubscribe();
subscription.unsubscribe();

// Advance for 30 more fake milliseconds, to make sure that second
// Observable still emits
Expand Down Expand Up @@ -1119,7 +1119,7 @@ describe('Component Store', () => {
origin$.pipe(tap((v) => results.push(typeof v)))
);
const effect = componentStore.effect(mockGenerator);
effect(undefined);
effect();
effect();

expect(results).toEqual(['undefined', 'undefined']);
Expand All @@ -1130,7 +1130,7 @@ describe('Component Store', () => {
'is run when observable is provided',
marbles((m) => {
const mockGenerator = jest.fn((origin$) => origin$);
const effect = componentStore.effect(mockGenerator);
const effect = componentStore.effect<string>(mockGenerator);

effect(m.cold('-a-b-c|'));

Expand All @@ -1143,7 +1143,7 @@ describe('Component Store', () => {
'is run with multiple Observables',
marbles((m) => {
const mockGenerator = jest.fn((origin$) => origin$);
const effect = componentStore.effect(mockGenerator);
const effect = componentStore.effect<string>(mockGenerator);

effect(m.cold('-a-b-c|'));
effect(m.hot(' --d--e----f-'));
Expand All @@ -1170,12 +1170,12 @@ describe('Component Store', () => {
);

// Update with Observable.
const subsription = effect(observable$);
const subscription = effect(observable$);

// Advance for 40 fake milliseconds and unsubscribe - should capture
// from '0' to '3'
advance(40);
subsription.unsubscribe();
subscription.unsubscribe();

// Advance for 20 more fake milliseconds, to check if anything else
// is captured
Expand All @@ -1196,7 +1196,7 @@ describe('Component Store', () => {
);

// Pass the first Observable to the effect.
const subsription = effect(
const subscription = effect(
interval(10).pipe(
map((v) => ({ value: 'a' + v })),
take(10) // just in case
Expand All @@ -1214,7 +1214,7 @@ describe('Component Store', () => {
// Advance for 40 fake milliseconds and unsubscribe - should capture
// from '0' to '3'
advance(40);
subsription.unsubscribe();
subscription.unsubscribe();

// Advance for 30 more fake milliseconds, to make sure that second
// Observable still emits
Expand All @@ -1236,7 +1236,7 @@ describe('Component Store', () => {
);

it('completes when componentStore is destroyed', (doneFn: jest.DoneCallback) => {
componentStore.effect((origin$) =>
componentStore.effect((origin$: Observable<number>) =>
origin$.pipe(
finalize(() => {
doneFn();
Expand All @@ -1249,7 +1249,7 @@ describe('Component Store', () => {
});

it('observable argument completes when componentStore is destroyed', (doneFn: jest.DoneCallback) => {
componentStore.effect((origin$) => origin$)(
componentStore.effect((origin$: Observable<number>) => origin$)(
interval(10).pipe(
finalize(() => {
doneFn();
Expand Down
158 changes: 158 additions & 0 deletions modules/component-store/spec/types/component-store.types.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { expecter } from 'ts-snippet';
import { compilerOptions } from './utils';

describe('ComponentStore types', () => {
describe('effect', () => {
const expectSnippet = expecter(
(code) => `
import { ComponentStore } from '@ngrx/component-store';
import { of, EMPTY, Observable } from 'rxjs';
import { concatMap } from 'rxjs/operators';
const number$: Observable<number> = of(5);
const string$: Observable<string> = of('string');
const componentStore = new ComponentStore();
${code}
`,
compilerOptions()
);

describe('infers Subscription', () => {
it('when argument type is specified and a variable with corresponding type is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<string>) => number$)('string');`
).toInfer('eff', 'Subscription');
});

it(
'when argument type is specified, returns EMPTY and ' +
'a variable with corresponding type is passed',
() => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<string>) => EMPTY)('string');`
).toInfer('eff', 'Subscription');
}
);

it('when argument type is specified and an Observable with corresponding type is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<string>) => EMPTY)(string$);`
).toInfer('eff', 'Subscription');
});

it('when argument type is specified as Observable<unknown> and any type is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<unknown>) => EMPTY)(5);`
).toInfer('eff', 'Subscription');
});

it('when generic type is specified and a variable with corresponding type is passed', () => {
expectSnippet(
`const eff = componentStore.effect<string>((e) => number$)('string');`
).toInfer('eff', 'Subscription');
});

it('when generic type is specified as unknown and a variable with any type is passed', () => {
expectSnippet(
`const eff = componentStore.effect<unknown>((e) => number$)('string');`
).toInfer('eff', 'Subscription');
});

it('when generic type is specified as unknown and origin can still be piped', () => {
expectSnippet(
`const eff = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
).toInfer('eff', 'Subscription');
});

it('when generic type is specified as unknown and origin can still be piped', () => {
expectSnippet(
`const eff = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
).toInfer('eff', 'Subscription');
});
});

describe('infers void', () => {
it('when argument type is specified as Observable<void> and nothing is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e: Observable<void>) => string$)();`
).toInfer('eff', 'void');
});

it('when type is not specified and origin can still be piped', () => {
expectSnippet(
// treated as Observable<void> 👇
`const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))();`
).toInfer('eff', 'void');
});

it('when generic type is specified as void and origin can still be piped', () => {
expectSnippet(
`const eff = componentStore.effect<void>((e) => e.pipe(concatMap(() => number$)))();`
).toInfer('eff', 'void');
});
});

describe('catches improper usage', () => {
it('when type is specified and argument is not passed', () => {
expectSnippet(
`componentStore.effect((e: Observable<string>) => of())();`
).toFail(/Expected 1 arguments, but got 0/);
});

it('when type is specified and argument of incorrect type is passed', () => {
expectSnippet(
`componentStore.effect((e: Observable<string>) => number$)(5);`
).toFail(
/Argument of type '5' is not assignable to parameter of type 'string \| Observable<string>'./
);
});

it('when type is specified and Observable argument of incorrect type is passed', () => {
expectSnippet(
`componentStore.effect((e: Observable<string>) => string$)(number$);`
).toFail(
/Argument of type 'Observable<number>' is not assignable to parameter of type 'string \| Observable<string>'/
);
});

it('when argument type is specified as Observable<unknown> and type is not passed', () => {
expectSnippet(
`componentStore.effect((e: Observable<unknown>) => EMPTY)();`
).toFail(/Expected 1 arguments, but got 0/);
});

it('when generic type is specified and a variable with incorrect type is passed', () => {
expectSnippet(
`componentStore.effect<string>((e) => number$)(5);`
).toFail(
/Argument of type '5' is not assignable to parameter of type 'string \| Observable<string>'/
);
});

it('when generic type is specified as unknown and a variable is not passed', () => {
expectSnippet(
`componentStore.effect<unknown>((e) => number$)();`
).toFail(/Expected 1 arguments, but got 0/);
});

it('when argument type is specified as Observable<void> and anything is passed', () => {
expectSnippet(
`componentStore.effect((e: Observable<void>) => string$)(5);`
).toFail(/Expected 0 arguments, but got 1/);
});

it('when type is not specified and anything is passed', () => {
expectSnippet(
`const eff = componentStore.effect((e) => EMPTY)('string');`
).toFail(/Expected 0 arguments, but got 1/);
});

it('when generic type is specified and anything is passed', () => {
expectSnippet(
`componentStore.effect<void>((e) => EMPTY)(undefined);`
).toFail(/Expected 0 arguments, but got 1/);
});
});
});
});
9 changes: 9 additions & 0 deletions modules/component-store/spec/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const compilerOptions = () => ({
moduleResolution: 'node',
target: 'es2017',
baseUrl: '.',
experimentalDecorators: true,
paths: {
'@ngrx/component-store': ['./modules/component-store'],
},
});
44 changes: 25 additions & 19 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ import {
Inject,
} from '@angular/core';

/**
* Return type of the effect, that behaves differently based on whether the
* argument is passed to the callback.
*/
export interface EffectReturnFn<T> {
(): void;
(t: T | Observable<T>): Subscription;
}

export interface SelectConfig {
debounce?: boolean;
}
Expand All @@ -47,7 +38,7 @@ export const initialStateToken = new InjectionToken('ComponentStore InitState');
export class ComponentStore<T extends object> implements OnDestroy {
// Should be used only in ngOnDestroy.
private readonly destroySubject$ = new ReplaySubject<void>(1);
// Exposed to any extending Store to be used for the teardowns.
// Exposed to any extending Store to be used for the teardown.
readonly destroy$ = this.destroySubject$.asObservable();

private readonly stateSubject$ = new ReplaySubject<T>(1);
Expand Down Expand Up @@ -83,7 +74,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
* current state and an argument object) and returns a new instance of the
* state.
* @return A function that accepts one argument which is forwarded as the
* second argument to `updaterFn`. Everytime this function is called
* second argument to `updaterFn`. Every time this function is called
* subscribers will be notified of the state change.
*/
updater<V>(
Expand Down Expand Up @@ -175,7 +166,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
*
* @param projector A pure projection function that takes the current state and
* returns some new slice/projection of that state.
* @param config SelectConfig that changes the behavoir of selector, including
* @param config SelectConfig that changes the behavior of selector, including
* the debouncing of the values until the state is settled.
* @return An observable of the projector results.
*/
Expand Down Expand Up @@ -247,24 +238,39 @@ export class ComponentStore<T extends object> implements OnDestroy {
* subscribed to for the life of the component.
* @return A function that, when called, will trigger the origin Observable.
*/
effect<V, R = unknown>(
generator: (origin$: Observable<V>) => Observable<R>
): EffectReturnFn<V> {
const origin$ = new Subject<V>();
generator(origin$)
effect<
// This type quickly became part of effect 'API'
ProvidedType = void,
// The actual origin$ type, which could be unknown, when not specified
OriginType extends Observable<ProvidedType> | unknown = Observable<
ProvidedType
>,
// Unwrapped actual type of the origin$ Observable, after default was applied
ObservableType = OriginType extends Observable<infer A> ? A : never,
// Return either an empty callback or a function requiring specific types as inputs
ReturnType = ProvidedType | ObservableType extends void
? () => void
: (
observableOrValue: ObservableType | Observable<ObservableType>
) => Subscription
>(generator: (origin$: OriginType) => Observable<unknown>): ReturnType {
const origin$ = new Subject<ObservableType>();
generator(origin$ as OriginType)
// tied to the lifecycle 👇 of ComponentStore
.pipe(takeUntil(this.destroy$))
.subscribe();

return (observableOrValue?: V | Observable<V>): Subscription => {
return (((
observableOrValue?: ObservableType | Observable<ObservableType>
): Subscription => {
const observable$ = isObservable(observableOrValue)
? observableOrValue
: of(observableOrValue);
return observable$.pipe(takeUntil(this.destroy$)).subscribe((value) => {
// any new 👇 value is pushed into a stream
origin$.next(value);
});
};
}) as unknown) as ReturnType;
}
}

Expand Down

0 comments on commit ee92912

Please sign in to comment.