Skip to content

Commit f12e1f1

Browse files
committed
feat(tap-once): enhance tapOnce and tapOnceOnFirstTruthy to ensure unique state per subscription
1 parent 561bdf5 commit f12e1f1

File tree

2 files changed

+81
-26
lines changed

2 files changed

+81
-26
lines changed

libs/ngxtension/tapOnce/src/tap-once.spec.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,60 @@ import { from, toArray } from 'rxjs';
22
import { tapOnce, tapOnceOnFirstTruthy } from './tap-once';
33

44
describe(tapOnce.name, () => {
5-
it('should execute the function only once at the specified index', (done) => {
5+
it('should execute the function only once at the default index 0', (done) => {
66
const tapFn = jest.fn();
77
const in$ = from([1, 2, 3, 4, 5]);
8-
const out$ = in$.pipe(tapOnce(tapFn, 2));
8+
const out$ = in$.pipe(tapOnce(tapFn));
99

1010
out$.pipe(toArray()).subscribe((r) => {
1111
expect(r).toEqual([1, 2, 3, 4, 5]);
1212
expect(tapFn).toHaveBeenCalledTimes(1);
13-
expect(tapFn).toHaveBeenCalledWith(3);
13+
expect(tapFn).toHaveBeenCalledWith(1);
1414
done();
1515
});
1616
});
1717

18-
it('should execute the function only once at the default index 0', (done) => {
18+
it('should execute the function only once at the specified index', (done) => {
1919
const tapFn = jest.fn();
2020
const in$ = from([1, 2, 3, 4, 5]);
21-
const out$ = in$.pipe(tapOnce(tapFn));
21+
const out$ = in$.pipe(tapOnce(tapFn, 2));
2222

2323
out$.pipe(toArray()).subscribe((r) => {
2424
expect(r).toEqual([1, 2, 3, 4, 5]);
2525
expect(tapFn).toHaveBeenCalledTimes(1);
26-
expect(tapFn).toHaveBeenCalledWith(1);
26+
expect(tapFn).toHaveBeenCalledWith(3);
2727
done();
2828
});
2929
});
3030

3131
it('should throw an error if tapIndex is negative', () => {
32-
expect(() => tapOnce(() => {}, -1)).toThrow(
32+
expect(() => tapOnce(() => void 0, -1)).toThrow(
3333
'tapIndex must be a non-negative integer',
3434
);
3535
});
36+
37+
it('should not share state across multiple subscriptions', (done) => {
38+
const tapFn = jest.fn();
39+
const in$ = from([1, 2, 3, 4, 5]);
40+
const out$ = in$.pipe(tapOnce(tapFn, 1));
41+
42+
// First subscription
43+
out$.pipe(toArray()).subscribe((r1) => {
44+
expect(r1).toEqual([1, 2, 3, 4, 5]);
45+
expect(tapFn).toHaveBeenCalledTimes(1);
46+
expect(tapFn).toHaveBeenCalledWith(2);
47+
48+
tapFn.mockClear();
49+
50+
// Second subscription - should also trigger the tap function
51+
out$.pipe(toArray()).subscribe((r2) => {
52+
expect(r2).toEqual([1, 2, 3, 4, 5]);
53+
expect(tapFn).toHaveBeenCalledTimes(1);
54+
expect(tapFn).toHaveBeenCalledWith(2);
55+
done();
56+
});
57+
});
58+
});
3659
});
3760

3861
describe(tapOnceOnFirstTruthy.name, () => {
@@ -73,4 +96,27 @@ describe(tapOnceOnFirstTruthy.name, () => {
7396
done();
7497
});
7598
});
99+
100+
it('should not share state across multiple subscriptions', (done) => {
101+
const tapFn = jest.fn();
102+
const in$ = from([0, null, false, 3, 4, 5]);
103+
const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn));
104+
105+
// First subscription
106+
out$.pipe(toArray()).subscribe((r1) => {
107+
expect(r1).toEqual([0, null, false, 3, 4, 5]);
108+
expect(tapFn).toHaveBeenCalledTimes(1);
109+
expect(tapFn).toHaveBeenCalledWith(3);
110+
111+
tapFn.mockClear();
112+
113+
// Second subscription - should also trigger the tap function
114+
out$.pipe(toArray()).subscribe((r2) => {
115+
expect(r2).toEqual([0, null, false, 3, 4, 5]);
116+
expect(tapFn).toHaveBeenCalledTimes(1);
117+
expect(tapFn).toHaveBeenCalledWith(3);
118+
done();
119+
});
120+
});
121+
});
76122
});
Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
1-
import { MonoTypeOperatorFunction, concatMap, of, type Observable } from 'rxjs';
2-
import { tap } from 'rxjs/operators';
1+
import { defer, MonoTypeOperatorFunction, Observable, tap } from 'rxjs';
32

43
/**
54
* Executes the provided function only once when the first truthy value is emitted.
5+
* Uses `defer` to ensure state is unique per subscription.
6+
*
67
* @param tapFn - Function to execute on the first truthy value.
78
* @returns MonoTypeOperatorFunction
89
*/
910
export function tapOnceOnFirstTruthy<T>(
1011
tapFn: (t: T) => void,
1112
): MonoTypeOperatorFunction<T> {
12-
let firstTruthy = true;
1313
return (source$: Observable<T>) =>
14-
source$.pipe(
15-
tap((value) => {
16-
if (firstTruthy && !!value) {
17-
tapFn(value);
18-
firstTruthy = false;
19-
}
20-
}),
21-
);
14+
defer(() => {
15+
let firstTruthy = true;
16+
return source$.pipe(
17+
tap((value) => {
18+
if (firstTruthy && !!value) {
19+
tapFn(value);
20+
firstTruthy = false;
21+
}
22+
}),
23+
);
24+
});
2225
}
2326

2427
/**
2528
* Executes the provided function only once when the value at the specified index is emitted.
29+
* Uses `defer` to track index without the overhead of `concatMap`.
30+
*
2631
* @param tapFn - Function to execute on the value at the specified index.
2732
* @param tapIndex - Index at which to execute the function (default is 0).
2833
* @returns MonoTypeOperatorFunction
@@ -34,13 +39,17 @@ export function tapOnce<T>(
3439
if (tapIndex < 0) {
3540
throw new Error('tapIndex must be a non-negative integer');
3641
}
42+
3743
return (source$: Observable<T>) =>
38-
source$.pipe(
39-
concatMap((value, index) => {
40-
if (index === tapIndex) {
41-
tapFn(value);
42-
}
43-
return of(value);
44-
}),
45-
);
44+
defer(() => {
45+
let currentIndex = 0;
46+
return source$.pipe(
47+
tap((value) => {
48+
if (currentIndex === tapIndex) {
49+
tapFn(value);
50+
}
51+
currentIndex++;
52+
}),
53+
);
54+
});
4655
}

0 commit comments

Comments
 (0)