Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(component-store): fine-tune effect types #2645

Merged
merged 1 commit into from
Aug 5, 2020
Merged
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
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