Skip to content

Commit

Permalink
fix(signal-slice): add undocumented (intentional) way to wait for sta…
Browse files Browse the repository at this point in the history
…te update from invoking reducers
  • Loading branch information
nartc committed Nov 14, 2023
1 parent 7b5224f commit 5370778
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 18 deletions.
87 changes: 85 additions & 2 deletions libs/ngxtension/signal-slice/src/signal-slice.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { Observable, Subject, of } from 'rxjs';
import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing';
import { Observable, Subject, of, timer } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { SignalSlice, signalSlice } from './signal-slice';

Expand Down Expand Up @@ -129,6 +129,26 @@ describe(signalSlice.name, () => {
expect(state.increaseAge$).toBeDefined();
});
});

it('should resolve the updated state as a promise after reducer is invoked', (done) => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
reducers: {
increaseAge: (state, amount: number) => ({
age: state.age + amount,
}),
},
});

state.increaseAge(1).then((updated) => {
expect(updated.age).toEqual(initialState.age + 1);
done();
});

TestBed.flushEffects();
});
});
});

describe('asyncReducers', () => {
Expand Down Expand Up @@ -168,6 +188,69 @@ describe(signalSlice.name, () => {
expect(state.load$).toBeDefined();
});
});

it('should resolve to the updated state when async reducer is invoked with a raw value', (done) => {
TestBed.runInInjectionContext(() => {
const state = signalSlice({
initialState,
asyncReducers: {
load: (_state, $: Observable<void>) =>
$.pipe(
switchMap(() => of(35)),
map((age) => ({ age }))
),
},
});

state.load().then((val) => {
expect(val.age).toEqual(35);
done();
});
TestBed.flushEffects();
});
});

it.only('should resolve to the updated state when async reducer is invoked with a stream and that stream is completed', fakeAsync(() => {
TestBed.runInInjectionContext(() => {
const age$ = new Subject<number>();

const state = signalSlice({
initialState,
asyncReducers: {
load: (_state, $: Observable<number>) =>
$.pipe(
switchMap((age) =>
timer(500).pipe(map(() => ({ age: 35 + age })))
)
),
},
});

state.load(age$).then((val) => {
expect(val.age).toEqual(40);
flush();
});

age$.next(1);
tick(500);

age$.next(2);
tick(500);

age$.next(3);
tick(500);

age$.next(4);
tick(500);

age$.next(5);
tick(500);

// NOTE: promise won't resolve until the stream is completed
age$.complete();
TestBed.flushEffects();
});
}));
});

describe('selectors', () => {
Expand Down
51 changes: 35 additions & 16 deletions libs/ngxtension/signal-slice/src/signal-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
type EffectRef,
type Signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { connect, type PartialOrValue, type Reducer } from 'ngxtension/connect';
import { Subject, isObservable, type Observable } from 'rxjs';
import { Subject, isObservable, take, type Observable } from 'rxjs';

type NamedReducers<TSignalValue> = {
[actionName: string]: (
Expand Down Expand Up @@ -45,19 +45,19 @@ type Effects<TEffects extends NamedEffects> = {
[K in keyof TEffects]: EffectRef;
};

type Action<TValue> = TValue extends void
? () => void
type Action<TSignalValue, TValue> = TValue extends void
? () => Promise<TSignalValue>
: unknown extends TValue
? () => void
: (value: TValue | Observable<TValue>) => void;
? () => Promise<TSignalValue>
: (value: TValue | Observable<TValue>) => Promise<TSignalValue>;

type ActionMethod<
TSignalValue,
TReducer extends NamedReducers<TSignalValue>[string]
> = TReducer extends (state: TSignalValue, value: infer TValue) => any
? TValue extends Observable<infer TObservableValue>
? Action<TObservableValue>
: Action<TValue>
? Action<TSignalValue, TObservableValue>
: Action<TSignalValue, TValue>
: never;

type AsyncActionMethod<
Expand All @@ -68,8 +68,8 @@ type AsyncActionMethod<
value: infer TValue
) => any
? TValue extends Observable<infer TObservableValue>
? Action<TObservableValue>
: Action<TValue>
? Action<TSignalValue, TObservableValue>
: Action<TSignalValue, TValue>
: never;

type ActionMethods<
Expand Down Expand Up @@ -163,8 +163,9 @@ export function signalSlice<
} = config;

const state = signal(initialState);

const readonlyState = state.asReadonly();
const state$ = toObservable(state);

const subs: Subject<unknown>[] = [];

for (const source of sources) {
Expand All @@ -179,7 +180,7 @@ export function signalSlice<
const subject = new Subject();

connect(state, subject, reducer);
addReducerProperties(readonlyState, key, destroyRef, subject, subs);
addReducerProperties(readonlyState, state$, key, destroyRef, subject, subs);
}

for (const [key, asyncReducer] of Object.entries(
Expand All @@ -188,7 +189,7 @@ export function signalSlice<
const subject = new Subject();
const observable = asyncReducer(readonlyState, subject);
connect(state, observable);
addReducerProperties(readonlyState, key, destroyRef, subject, subs);
addReducerProperties(readonlyState, state$, key, destroyRef, subject, subs);
}

for (const key in initialState) {
Expand Down Expand Up @@ -231,6 +232,7 @@ export function signalSlice<

function addReducerProperties(
readonlyState: Signal<unknown>,
state$: Observable<unknown>,
key: string,
destroyRef: DestroyRef,
subject: Subject<unknown>,
Expand All @@ -240,10 +242,27 @@ function addReducerProperties(
[key]: {
value: (nextValue: unknown) => {
if (isObservable(nextValue)) {
nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe(subject);
} else {
subject.next(nextValue);
return new Promise((res, rej) => {
nextValue.pipe(takeUntilDestroyed(destroyRef)).subscribe({
next: subject.next.bind(subject),
error: (err) => {
subject.error(err);
rej(err);
},
complete: () => {
subject.complete();
res(readonlyState());
},
});
});
}

return new Promise((res) => {
state$.pipe(take(1)).subscribe((val) => {
res(val);
});
subject.next(nextValue);
});
},
},
[`${key}$`]: {
Expand Down

0 comments on commit 5370778

Please sign in to comment.