synstate 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/core/base/create-child-observable.d.mts +9 -2
- package/dist/core/base/create-child-observable.d.mts.map +1 -1
- package/dist/core/base/create-child-observable.mjs +49 -11
- package/dist/core/base/create-child-observable.mjs.map +1 -1
- package/dist/core/base/create-observable-base.d.mts +5 -1
- package/dist/core/base/create-observable-base.d.mts.map +1 -1
- package/dist/core/base/create-observable-base.mjs +24 -4
- package/dist/core/base/create-observable-base.mjs.map +1 -1
- package/dist/core/base/create-root-observable.d.mts +9 -0
- package/dist/core/base/create-root-observable.d.mts.map +1 -1
- package/dist/core/base/create-root-observable.mjs +32 -3
- package/dist/core/base/create-root-observable.mjs.map +1 -1
- package/dist/core/combine/merge.d.mts +0 -3
- package/dist/core/combine/merge.d.mts.map +1 -1
- package/dist/core/combine/merge.mjs +0 -3
- package/dist/core/combine/merge.mjs.map +1 -1
- package/dist/core/combine/zip.d.mts +4 -2
- package/dist/core/combine/zip.d.mts.map +1 -1
- package/dist/core/combine/zip.mjs +21 -3
- package/dist/core/combine/zip.mjs.map +1 -1
- package/dist/core/create/counter.d.mts +9 -3
- package/dist/core/create/counter.d.mts.map +1 -1
- package/dist/core/create/counter.mjs +16 -3
- package/dist/core/create/counter.mjs.map +1 -1
- package/dist/core/create/from-subscribable.d.mts.map +1 -1
- package/dist/core/create/from-subscribable.mjs +34 -11
- package/dist/core/create/from-subscribable.mjs.map +1 -1
- package/dist/core/create/source.d.mts +7 -0
- package/dist/core/create/source.d.mts.map +1 -1
- package/dist/core/create/source.mjs +6 -2
- package/dist/core/create/source.mjs.map +1 -1
- package/dist/core/operators/merge-map.d.mts +0 -3
- package/dist/core/operators/merge-map.d.mts.map +1 -1
- package/dist/core/operators/merge-map.mjs +0 -3
- package/dist/core/operators/merge-map.mjs.map +1 -1
- package/dist/core/operators/skip-until.d.mts.map +1 -1
- package/dist/core/operators/skip-until.mjs +15 -6
- package/dist/core/operators/skip-until.mjs.map +1 -1
- package/dist/core/operators/switch-map.d.mts +0 -3
- package/dist/core/operators/switch-map.d.mts.map +1 -1
- package/dist/core/operators/switch-map.mjs +0 -3
- package/dist/core/operators/switch-map.mjs.map +1 -1
- package/dist/core/operators/take-until.d.mts.map +1 -1
- package/dist/core/operators/take-until.mjs +13 -1
- package/dist/core/operators/take-until.mjs.map +1 -1
- package/dist/core/operators/with-buffered-from.d.mts.map +1 -1
- package/dist/core/operators/with-buffered-from.mjs +12 -3
- package/dist/core/operators/with-buffered-from.mjs.map +1 -1
- package/dist/core/types/observable-family.d.mts +5 -1
- package/dist/core/types/observable-family.d.mts.map +1 -1
- package/dist/core/types/observable-family.mjs.map +1 -1
- package/dist/core/types/observable.d.mts +17 -0
- package/dist/core/types/observable.d.mts.map +1 -1
- package/dist/core/types/observable.mjs.map +1 -1
- package/dist/utils/create-event-emitter.d.mts +2 -2
- package/dist/utils/create-reducer.d.mts +10 -1
- package/dist/utils/create-reducer.d.mts.map +1 -1
- package/dist/utils/create-reducer.mjs +13 -5
- package/dist/utils/create-reducer.mjs.map +1 -1
- package/dist/utils/create-state.d.mts +7 -0
- package/dist/utils/create-state.d.mts.map +1 -1
- package/dist/utils/create-state.mjs +7 -0
- package/dist/utils/create-state.mjs.map +1 -1
- package/package.json +4 -4
- package/src/core/base/create-child-observable.mts +68 -10
- package/src/core/base/create-observable-base.mts +31 -3
- package/src/core/base/create-root-observable.mts +43 -2
- package/src/core/base/write-during-propagation.test.mts +234 -0
- package/src/core/combine/merge.mts +0 -3
- package/src/core/combine/zip.mts +36 -5
- package/src/core/create/counter.mts +27 -6
- package/src/core/create/counter.test.mts +82 -0
- package/src/core/create/from-subscribable.mts +33 -5
- package/src/core/create/from-subscribable.test.mts +129 -0
- package/src/core/create/source.mts +18 -3
- package/src/core/create/source.test.mts +41 -0
- package/src/core/operators/merge-map.mts +0 -3
- package/src/core/operators/skip-until.mts +21 -9
- package/src/core/operators/switch-map.mts +0 -3
- package/src/core/operators/take-until-skip-until-teardown.test.mts +127 -0
- package/src/core/operators/take-until.mts +15 -1
- package/src/core/operators/with-buffered-from.mts +15 -3
- package/src/core/operators/with-buffered-from.test.mts +56 -0
- package/src/core/types/observable-family.mts +5 -1
- package/src/core/types/observable.mts +19 -0
- package/src/utils/create-event-emitter.mts +2 -2
- package/src/utils/create-reducer.mts +14 -8
- package/src/utils/create-reducer.test.mts +12 -0
- package/src/utils/create-state.mts +7 -0
- package/src/utils/create-state.test.mts +20 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Result } from 'ts-data-forge';
|
|
2
|
+
import type { FixedLengthTuple } from 'ts-type-forge';
|
|
3
|
+
import { createState } from '../../utils/index.mjs';
|
|
4
|
+
import { combine } from '../combine/index.mjs';
|
|
5
|
+
import { fromSubscribable, source } from '../create/index.mjs';
|
|
6
|
+
import { map, switchMap } from '../operators/index.mjs';
|
|
7
|
+
import { writeDuringPropagationWarning } from './create-root-observable.mjs';
|
|
8
|
+
|
|
9
|
+
describe('a write to a root observable while an update is propagating (#2336)', () => {
|
|
10
|
+
test('warns when a subscriber writes to another state, and still propagates the write immediately', async () => {
|
|
11
|
+
const [a$, setA] = createState(0);
|
|
12
|
+
|
|
13
|
+
const [b$, setB] = createState(0);
|
|
14
|
+
|
|
15
|
+
const doubled$ = a$.pipe(map((a) => a * 10));
|
|
16
|
+
|
|
17
|
+
a$.subscribe((a) => {
|
|
18
|
+
setB(a * 2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const mut_history: FixedLengthTuple<2, number>[] = [];
|
|
22
|
+
|
|
23
|
+
combine([doubled$, b$]).subscribe((v) => {
|
|
24
|
+
mut_history.push(v);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const warn = await captureWarnings(() => {
|
|
28
|
+
setA(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.deepStrictEqual(warn, [writeDuringPropagationWarning]);
|
|
32
|
+
|
|
33
|
+
// The behavior is unchanged: b$ propagates before doubled$ is updated, so
|
|
34
|
+
// the intermediate state [0, 2] is observed.
|
|
35
|
+
assert.deepStrictEqual(mut_history, [
|
|
36
|
+
[0, 0],
|
|
37
|
+
[0, 2],
|
|
38
|
+
[10, 2],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(b$.getSnapshot().value, 2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('warns when a projection writes to a state', async () => {
|
|
45
|
+
const a$ = source<number>(0);
|
|
46
|
+
|
|
47
|
+
const [, setB] = createState(0);
|
|
48
|
+
|
|
49
|
+
a$.pipe(
|
|
50
|
+
map((a) => {
|
|
51
|
+
setB(a);
|
|
52
|
+
|
|
53
|
+
return a;
|
|
54
|
+
}),
|
|
55
|
+
).subscribe(() => {});
|
|
56
|
+
|
|
57
|
+
const warn = await captureWarnings(() => {
|
|
58
|
+
a$.next(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.deepStrictEqual(warn, [writeDuringPropagationWarning]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('warns when a subscriber of an asynchronous child writes to a state', async () => {
|
|
65
|
+
const outer$ = source<number>(0);
|
|
66
|
+
|
|
67
|
+
const inner$ = source<number>(0);
|
|
68
|
+
|
|
69
|
+
const [, setB] = createState(0);
|
|
70
|
+
|
|
71
|
+
outer$.pipe(switchMap(() => inner$)).subscribe((v) => {
|
|
72
|
+
if (v > 0) {
|
|
73
|
+
setB(v);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const warnOnSwitch = await captureWarnings(() => {
|
|
78
|
+
outer$.next(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.deepStrictEqual(warnOnSwitch, []);
|
|
82
|
+
|
|
83
|
+
const warn = await captureWarnings(() => {
|
|
84
|
+
inner$.next(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.deepStrictEqual(warn, [writeDuringPropagationWarning]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('warns once per observable', async () => {
|
|
91
|
+
const a$ = source<number>(0);
|
|
92
|
+
|
|
93
|
+
const [, setB] = createState(0);
|
|
94
|
+
|
|
95
|
+
const [, setC] = createState(0);
|
|
96
|
+
|
|
97
|
+
a$.subscribe((a) => {
|
|
98
|
+
if (a <= 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setB(a);
|
|
103
|
+
|
|
104
|
+
setB(a + 1);
|
|
105
|
+
|
|
106
|
+
setC(a);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const warn = await captureWarnings(() => {
|
|
110
|
+
a$.next(1);
|
|
111
|
+
|
|
112
|
+
a$.next(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepStrictEqual(warn, [
|
|
116
|
+
writeDuringPropagationWarning,
|
|
117
|
+
writeDuringPropagationWarning,
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('does not warn for writes outside propagation', async () => {
|
|
122
|
+
const [a$, setA] = createState(0);
|
|
123
|
+
|
|
124
|
+
const [b$, setB] = createState(0);
|
|
125
|
+
|
|
126
|
+
a$.subscribe((a) => {
|
|
127
|
+
queueMicrotask(() => {
|
|
128
|
+
setB(a);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const warn = await captureWarnings(async () => {
|
|
133
|
+
setA(1);
|
|
134
|
+
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.deepStrictEqual(warn, []);
|
|
139
|
+
|
|
140
|
+
assert.strictEqual(b$.getSnapshot().value, 1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('does not warn when an operator emits an inner observable synchronously', async () => {
|
|
144
|
+
const outer$ = source<number>(0);
|
|
145
|
+
|
|
146
|
+
const mut_history: number[] = [];
|
|
147
|
+
|
|
148
|
+
outer$.pipe(switchMap((x) => source(x * 10))).subscribe((v) => {
|
|
149
|
+
mut_history.push(v);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const warn = await captureWarnings(() => {
|
|
153
|
+
outer$.next(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
assert.deepStrictEqual(warn, []);
|
|
157
|
+
|
|
158
|
+
assert.deepStrictEqual(mut_history, [10]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('does not warn when a root created during propagation emits while it is created', async () => {
|
|
162
|
+
const outer$ = source<number>(0);
|
|
163
|
+
|
|
164
|
+
const mut_history: number[] = [];
|
|
165
|
+
|
|
166
|
+
outer$
|
|
167
|
+
.pipe(
|
|
168
|
+
switchMap((x) =>
|
|
169
|
+
fromSubscribable<number>({
|
|
170
|
+
subscribe: (onNext) => {
|
|
171
|
+
onNext(x * 10);
|
|
172
|
+
|
|
173
|
+
return { unsubscribe: () => {} };
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
.subscribe((r) => {
|
|
179
|
+
if (Result.isOk(r)) {
|
|
180
|
+
mut_history.push(r.value);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const warn = await captureWarnings(() => {
|
|
185
|
+
outer$.next(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.deepStrictEqual(warn, []);
|
|
189
|
+
|
|
190
|
+
assert.deepStrictEqual(mut_history, [10]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a subscriber that throws does not leave the propagation flag set', async () => {
|
|
194
|
+
const a$ = source<number>(0);
|
|
195
|
+
|
|
196
|
+
const [, setB] = createState(0);
|
|
197
|
+
|
|
198
|
+
a$.subscribe((a) => {
|
|
199
|
+
if (a > 0) {
|
|
200
|
+
// @sumi-expect-error exceptions/no-throw
|
|
201
|
+
throw new Error('boom');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const warn = await captureWarnings(() => {
|
|
206
|
+
expect(() => {
|
|
207
|
+
a$.next(1);
|
|
208
|
+
}).toThrow('boom');
|
|
209
|
+
|
|
210
|
+
setB(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.deepStrictEqual(warn, []);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Runs `run` with `console.warn` replaced by a mock, and returns the messages
|
|
219
|
+
* it was called with.
|
|
220
|
+
*/
|
|
221
|
+
const captureWarnings = async (
|
|
222
|
+
run: () => Promise<void> | void,
|
|
223
|
+
): Promise<readonly unknown[]> => {
|
|
224
|
+
const warn = vi.fn();
|
|
225
|
+
|
|
226
|
+
vi.stubGlobal('console', { ...console, warn });
|
|
227
|
+
|
|
228
|
+
await run();
|
|
229
|
+
|
|
230
|
+
// cspell:disable-next-line
|
|
231
|
+
vi.unstubAllGlobals();
|
|
232
|
+
|
|
233
|
+
return warn.mock.calls.map((args: readonly unknown[]) => args[0]);
|
|
234
|
+
};
|
|
@@ -58,9 +58,6 @@ import type {
|
|
|
58
58
|
*
|
|
59
59
|
* assert.deepStrictEqual(valueHistory, ['c1', 'k1', 'c2', 'k2']);
|
|
60
60
|
* ```
|
|
61
|
-
*
|
|
62
|
-
* @note To improve code readability, consider using `createState` instead of `merge`,
|
|
63
|
-
* subscribing to `parents` and calling `setState` within it.
|
|
64
61
|
*/
|
|
65
62
|
export const merge = <const OS extends NonEmptyTuple<Observable<unknown>>>(
|
|
66
63
|
parents: OS,
|
package/src/core/combine/zip.mts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
Observable,
|
|
11
11
|
SyncChildObservable,
|
|
12
12
|
TupleToQueueTuple,
|
|
13
|
+
UpdateToken,
|
|
13
14
|
Wrap,
|
|
14
15
|
ZipObservable,
|
|
15
16
|
ZipObservableRefined,
|
|
@@ -18,7 +19,9 @@ import type {
|
|
|
18
19
|
/**
|
|
19
20
|
* Combines multiple observables by pairing their emissions by index.
|
|
20
21
|
* Waits for all sources to emit their nth value before emitting the nth tuple.
|
|
21
|
-
* Completes
|
|
22
|
+
* Completes once a completed source has no value left to pair, as RxJS's
|
|
23
|
+
* `zip` does: at once if it completes with nothing queued, otherwise after
|
|
24
|
+
* the tuple that takes its last queued value.
|
|
22
25
|
*
|
|
23
26
|
* @template OS - Tuple type of source observables
|
|
24
27
|
* @param parents - Array of observables to zip
|
|
@@ -35,7 +38,7 @@ import type {
|
|
|
35
38
|
* // Explanation:
|
|
36
39
|
* // - zip pairs values by their index from multiple sources
|
|
37
40
|
* // - Waits for all sources to emit at the same index
|
|
38
|
-
* // - Completes
|
|
41
|
+
* // - Completes once a completed source has no value left to pair
|
|
39
42
|
*
|
|
40
43
|
* const [letters$, setLetter] = createState<string>('A');
|
|
41
44
|
*
|
|
@@ -95,19 +98,43 @@ const createZipObservable = <const A extends NonEmptyUnknownList>(
|
|
|
95
98
|
|
|
96
99
|
const queues: TupleToQueueTuple<A> = Arr.map(parents, () => createQueue());
|
|
97
100
|
|
|
101
|
+
// The token of each parent's latest value that zip has taken in, so that a
|
|
102
|
+
// value a parent emits in the update it completes in — its `tryComplete`
|
|
103
|
+
// reaches zip before its `tryUpdate` does — still counts as queued.
|
|
104
|
+
const mut_takenTokens: UpdateToken[] = Array.from(
|
|
105
|
+
parents,
|
|
106
|
+
(p) => p.updateToken,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// No tuple can be completed any more once a completed source has nothing
|
|
110
|
+
// left to pair (#2343).
|
|
111
|
+
const canEmitNoMore = (): boolean =>
|
|
112
|
+
parents.some(
|
|
113
|
+
(par, index) =>
|
|
114
|
+
par.isCompleted &&
|
|
115
|
+
queues[index]?.isEmpty === true &&
|
|
116
|
+
(par.updateToken === mut_takenTokens[index] ||
|
|
117
|
+
Optional.isNone(par.getSnapshot())),
|
|
118
|
+
);
|
|
119
|
+
|
|
98
120
|
return createSyncChildObservable<A, A>(
|
|
99
121
|
{
|
|
100
122
|
parents,
|
|
101
123
|
initialValue,
|
|
124
|
+
canEmitNoMore,
|
|
102
125
|
},
|
|
103
|
-
({ setNext }) =>
|
|
126
|
+
({ setNext, complete }) =>
|
|
104
127
|
(updateToken) => {
|
|
105
128
|
for (const [index, par] of parents.entries()) {
|
|
106
129
|
const sn = par.getSnapshot();
|
|
107
130
|
|
|
108
|
-
if (par.updateToken === updateToken && Optional.isSome(sn)) {
|
|
109
|
-
|
|
131
|
+
if (!(par.updateToken === updateToken && Optional.isSome(sn))) {
|
|
132
|
+
continue;
|
|
110
133
|
}
|
|
134
|
+
|
|
135
|
+
queues[index]?.enqueue(sn.value);
|
|
136
|
+
|
|
137
|
+
mut_takenTokens[index] = updateToken;
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
if (queues.some((list) => list.isEmpty)) {
|
|
@@ -121,6 +148,10 @@ const createZipObservable = <const A extends NonEmptyUnknownList>(
|
|
|
121
148
|
Arr.map(queues, (q) => Optional.unwrap(q.dequeue())) as A;
|
|
122
149
|
|
|
123
150
|
setNext(nextValue, updateToken);
|
|
151
|
+
|
|
152
|
+
if (canEmitNoMore()) {
|
|
153
|
+
complete();
|
|
154
|
+
}
|
|
124
155
|
},
|
|
125
156
|
);
|
|
126
157
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asSafeUint, Optional, SafeUint } from 'ts-data-forge';
|
|
2
|
+
import type { PositiveSafeIntWithSmallInt } from 'ts-type-forge';
|
|
2
3
|
import { createRootObservable } from '../base/index.mjs';
|
|
3
4
|
import type { CounterObservable, TimerId } from '../types/index.mjs';
|
|
4
5
|
|
|
@@ -15,6 +16,10 @@ import type { CounterObservable, TimerId } from '../types/index.mjs';
|
|
|
15
16
|
* @param options.emitInitial - If false, skips the 0 emitted right after
|
|
16
17
|
* `start()`, so the first value is 1, one interval later — like RxJS
|
|
17
18
|
* `interval` rather than `timer(0, ms)` (default: true)
|
|
19
|
+
* @param options.times - If set, completes right after the `times`-th
|
|
20
|
+
* emission and clears its timers. Completion does not travel upstream, so
|
|
21
|
+
* `counter(ms).pipe(take(n))` leaves the counter running; pass `times` to
|
|
22
|
+
* the counter instead (default: unlimited)
|
|
18
23
|
* @returns An observable that emits sequential numbers
|
|
19
24
|
*
|
|
20
25
|
* @example
|
|
@@ -45,11 +50,11 @@ import type { CounterObservable, TimerId } from '../types/index.mjs';
|
|
|
45
50
|
*
|
|
46
51
|
* assert.isTrue(Arr.isMinLengthArray(3, valueHistory));
|
|
47
52
|
*
|
|
48
|
-
* assert.
|
|
53
|
+
* assert.strictEqual(valueHistory[0], 0);
|
|
49
54
|
*
|
|
50
|
-
* assert.
|
|
55
|
+
* assert.strictEqual(valueHistory[1], 1);
|
|
51
56
|
*
|
|
52
|
-
* assert.
|
|
57
|
+
* assert.strictEqual(valueHistory[2], 2);
|
|
53
58
|
* ```
|
|
54
59
|
*/
|
|
55
60
|
export const counter = (
|
|
@@ -57,6 +62,7 @@ export const counter = (
|
|
|
57
62
|
options?: Readonly<{
|
|
58
63
|
startManually?: boolean;
|
|
59
64
|
emitInitial?: boolean;
|
|
65
|
+
times?: PositiveSafeIntWithSmallInt;
|
|
60
66
|
}>,
|
|
61
67
|
): CounterObservable => {
|
|
62
68
|
let mut_counter: SafeUint = asSafeUint(0);
|
|
@@ -67,6 +73,8 @@ export const counter = (
|
|
|
67
73
|
|
|
68
74
|
let mut_isStarted = false;
|
|
69
75
|
|
|
76
|
+
let mut_emittedCount = 0;
|
|
77
|
+
|
|
70
78
|
const resetTimer = (): void => {
|
|
71
79
|
if (mut_timerId0 !== undefined) {
|
|
72
80
|
clearTimeout(mut_timerId0);
|
|
@@ -82,7 +90,7 @@ export const counter = (
|
|
|
82
90
|
Readonly<{ start: () => void }>
|
|
83
91
|
>(
|
|
84
92
|
{ initialValue: Optional.none, onComplete: resetTimer },
|
|
85
|
-
({ startUpdate, isCompleted }) => ({
|
|
93
|
+
({ startUpdate, isCompleted, complete }) => ({
|
|
86
94
|
start: () => {
|
|
87
95
|
if (mut_isStarted) {
|
|
88
96
|
console.warn('cannot start twice');
|
|
@@ -98,16 +106,29 @@ export const counter = (
|
|
|
98
106
|
return;
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
const emit = (value: SafeUint): void => {
|
|
110
|
+
startUpdate(value);
|
|
111
|
+
|
|
112
|
+
mut_emittedCount += 1;
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
options?.times !== undefined &&
|
|
116
|
+
mut_emittedCount >= options.times
|
|
117
|
+
) {
|
|
118
|
+
complete();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
101
122
|
if (options?.emitInitial ?? true) {
|
|
102
123
|
mut_timerId0 = setTimeout(() => {
|
|
103
|
-
|
|
124
|
+
emit(mut_counter);
|
|
104
125
|
}, 0);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
mut_timerId = setInterval(() => {
|
|
108
129
|
mut_counter = SafeUint.add(1, mut_counter);
|
|
109
130
|
|
|
110
|
-
|
|
131
|
+
emit(mut_counter);
|
|
111
132
|
}, intervalMilliSeconds);
|
|
112
133
|
},
|
|
113
134
|
}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Arr } from 'ts-data-forge';
|
|
2
|
+
import { map } from '../operators/index.mjs';
|
|
2
3
|
import { counter } from './counter.mjs';
|
|
3
4
|
|
|
4
5
|
describe(counter, () => {
|
|
@@ -191,4 +192,85 @@ describe(counter, () => {
|
|
|
191
192
|
|
|
192
193
|
vi.useRealTimers();
|
|
193
194
|
});
|
|
195
|
+
|
|
196
|
+
test.each([
|
|
197
|
+
{ emitInitial: true, expected: [0, 1, 2] },
|
|
198
|
+
{ emitInitial: false, expected: [1, 2, 3] },
|
|
199
|
+
] as const)(
|
|
200
|
+
'times: 3, emitInitial: $emitInitial completes right after the third emission',
|
|
201
|
+
({ emitInitial, expected }) => {
|
|
202
|
+
vi.useFakeTimers();
|
|
203
|
+
|
|
204
|
+
const counter$ = counter(100, { times: 3, emitInitial });
|
|
205
|
+
|
|
206
|
+
const mut_history: number[] = [];
|
|
207
|
+
|
|
208
|
+
counter$.subscribe((n) => {
|
|
209
|
+
mut_history.push(n);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
vi.advanceTimersToNextTimer().advanceTimersToNextTimer();
|
|
213
|
+
|
|
214
|
+
assert.deepStrictEqual<readonly number[]>(
|
|
215
|
+
mut_history,
|
|
216
|
+
Arr.take(expected, 2),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
assert.isFalse(counter$.isCompleted);
|
|
220
|
+
|
|
221
|
+
vi.advanceTimersToNextTimer();
|
|
222
|
+
|
|
223
|
+
assert.deepStrictEqual<readonly number[]>(mut_history, expected);
|
|
224
|
+
|
|
225
|
+
assert.isTrue(counter$.isCompleted);
|
|
226
|
+
|
|
227
|
+
assert.strictEqual(vi.getTimerCount(), 0);
|
|
228
|
+
|
|
229
|
+
vi.useRealTimers();
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
test('times: 1 emits only the 0 and clears the interval', () => {
|
|
234
|
+
vi.useFakeTimers();
|
|
235
|
+
|
|
236
|
+
const counter$ = counter(100, { times: 1 });
|
|
237
|
+
|
|
238
|
+
const mut_history: number[] = [];
|
|
239
|
+
|
|
240
|
+
counter$.subscribe((n) => {
|
|
241
|
+
mut_history.push(n);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
vi.advanceTimersToNextTimer();
|
|
245
|
+
|
|
246
|
+
assert.deepStrictEqual(mut_history, [0]);
|
|
247
|
+
|
|
248
|
+
assert.isTrue(counter$.isCompleted);
|
|
249
|
+
|
|
250
|
+
assert.strictEqual(vi.getTimerCount(), 0);
|
|
251
|
+
|
|
252
|
+
vi.useRealTimers();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('times completes the downstream after the last value reaches it', () => {
|
|
256
|
+
vi.useFakeTimers();
|
|
257
|
+
|
|
258
|
+
const counter$ = counter(100, { times: 2 });
|
|
259
|
+
|
|
260
|
+
const doubled$ = counter$.pipe(map((n) => n * 2));
|
|
261
|
+
|
|
262
|
+
const mut_history: number[] = [];
|
|
263
|
+
|
|
264
|
+
doubled$.subscribe((n) => {
|
|
265
|
+
mut_history.push(n);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
vi.advanceTimersToNextTimer().advanceTimersToNextTimer();
|
|
269
|
+
|
|
270
|
+
assert.deepStrictEqual(mut_history, [0, 2]);
|
|
271
|
+
|
|
272
|
+
assert.isTrue(doubled$.isCompleted);
|
|
273
|
+
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
});
|
|
194
276
|
});
|
|
@@ -3,6 +3,7 @@ import { createRootObservable } from '../base/index.mjs';
|
|
|
3
3
|
import type {
|
|
4
4
|
FromSubscribableObservable,
|
|
5
5
|
Subscribable,
|
|
6
|
+
Subscription,
|
|
6
7
|
} from '../types/index.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -65,11 +66,25 @@ import type {
|
|
|
65
66
|
*/
|
|
66
67
|
export const fromSubscribable = <A, E = unknown>(
|
|
67
68
|
subscribable: Subscribable<A>,
|
|
68
|
-
): FromSubscribableObservable<A, E> =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
): FromSubscribableObservable<A, E> => {
|
|
70
|
+
let mut_subscription: Subscription | undefined = undefined;
|
|
71
|
+
|
|
72
|
+
// Set once the subscribable has completed: its subscription is over, so
|
|
73
|
+
// completing this observable must not unsubscribe from it again.
|
|
74
|
+
let mut_isSourceCompleted = false;
|
|
75
|
+
|
|
76
|
+
const unsubscribe = (): void => {
|
|
77
|
+
if (!mut_isSourceCompleted) {
|
|
78
|
+
mut_subscription?.unsubscribe();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
mut_subscription = undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return createRootObservable<Result<A, E>>(
|
|
85
|
+
{ initialValue: Optional.none, onComplete: unsubscribe },
|
|
86
|
+
({ startUpdate, isCompleted, complete }) => {
|
|
87
|
+
const subscription = subscribable.subscribe(
|
|
73
88
|
(nextValue) => {
|
|
74
89
|
startUpdate(Result.ok(nextValue));
|
|
75
90
|
},
|
|
@@ -82,10 +97,23 @@ export const fromSubscribable = <A, E = unknown>(
|
|
|
82
97
|
);
|
|
83
98
|
},
|
|
84
99
|
() => {
|
|
100
|
+
mut_isSourceCompleted = true;
|
|
101
|
+
|
|
85
102
|
complete();
|
|
86
103
|
},
|
|
87
104
|
);
|
|
88
105
|
|
|
106
|
+
// `subscribe` may have completed this observable before returning
|
|
107
|
+
// the subscription, so `unsubscribe` above had nothing to release.
|
|
108
|
+
if (isCompleted()) {
|
|
109
|
+
if (!mut_isSourceCompleted) {
|
|
110
|
+
subscription.unsubscribe();
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
mut_subscription = subscription;
|
|
114
|
+
}
|
|
115
|
+
|
|
89
116
|
return {};
|
|
90
117
|
},
|
|
91
118
|
);
|
|
119
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Result } from 'ts-data-forge';
|
|
2
|
+
import { take } from '../predefined/index.mjs';
|
|
3
|
+
import type { Subscribable } from '../types/index.mjs';
|
|
4
|
+
import { fromSubscribable } from './from-subscribable.mjs';
|
|
5
|
+
|
|
6
|
+
describe(fromSubscribable, () => {
|
|
7
|
+
test('complete() unsubscribes from the subscribable once', () => {
|
|
8
|
+
const { subscribable, emit, unsubscribe } = createManualSubscribable();
|
|
9
|
+
|
|
10
|
+
const observable$ = fromSubscribable(subscribable);
|
|
11
|
+
|
|
12
|
+
observable$.complete();
|
|
13
|
+
|
|
14
|
+
observable$.complete();
|
|
15
|
+
|
|
16
|
+
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith();
|
|
17
|
+
|
|
18
|
+
emit(1);
|
|
19
|
+
|
|
20
|
+
assert.isTrue(observable$.isCompleted);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('dispose() unsubscribes from the subscribable', () => {
|
|
24
|
+
const { subscribable, unsubscribe } = createManualSubscribable();
|
|
25
|
+
|
|
26
|
+
fromSubscribable(subscribable).dispose();
|
|
27
|
+
|
|
28
|
+
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('does not unsubscribe when the subscribable completes first', () => {
|
|
32
|
+
const { subscribable, emit, completeSource, unsubscribe } =
|
|
33
|
+
createManualSubscribable();
|
|
34
|
+
|
|
35
|
+
const observable$ = fromSubscribable(subscribable);
|
|
36
|
+
|
|
37
|
+
const mut_history: number[] = [];
|
|
38
|
+
|
|
39
|
+
observable$.subscribe((result) => {
|
|
40
|
+
if (Result.isOk(result)) {
|
|
41
|
+
mut_history.push(result.value);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
emit(1);
|
|
46
|
+
|
|
47
|
+
completeSource();
|
|
48
|
+
|
|
49
|
+
assert.isTrue(observable$.isCompleted);
|
|
50
|
+
|
|
51
|
+
observable$.complete();
|
|
52
|
+
|
|
53
|
+
expect(unsubscribe).not.toHaveBeenCalled();
|
|
54
|
+
|
|
55
|
+
assert.deepStrictEqual(mut_history, [1]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not unsubscribe when the subscribable completes synchronously during subscribe', () => {
|
|
59
|
+
const unsubscribe = vi.fn<() => void>();
|
|
60
|
+
|
|
61
|
+
const observable$ = fromSubscribable<number>({
|
|
62
|
+
subscribe: (_onNext, _onError, onComplete) => {
|
|
63
|
+
onComplete?.();
|
|
64
|
+
|
|
65
|
+
return { unsubscribe };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.isTrue(observable$.isCompleted);
|
|
70
|
+
|
|
71
|
+
expect(unsubscribe).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a child that completes with nothing else using it unsubscribes from the subscribable', () => {
|
|
75
|
+
const { subscribable, emit, unsubscribe } = createManualSubscribable();
|
|
76
|
+
|
|
77
|
+
const observable$ = fromSubscribable(subscribable);
|
|
78
|
+
|
|
79
|
+
const taken$ = observable$.pipe(take(1));
|
|
80
|
+
|
|
81
|
+
taken$.subscribe(() => {});
|
|
82
|
+
|
|
83
|
+
emit(1);
|
|
84
|
+
|
|
85
|
+
emit(2);
|
|
86
|
+
|
|
87
|
+
assert.isTrue(taken$.isCompleted);
|
|
88
|
+
|
|
89
|
+
assert.isTrue(observable$.isCompleted);
|
|
90
|
+
|
|
91
|
+
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const createManualSubscribable = (): Readonly<{
|
|
96
|
+
subscribable: Subscribable<number>;
|
|
97
|
+
emit: (value: number) => void;
|
|
98
|
+
completeSource: () => void;
|
|
99
|
+
unsubscribe: () => void;
|
|
100
|
+
}> => {
|
|
101
|
+
let mut_onNext: ((value: number) => void) | undefined = undefined;
|
|
102
|
+
|
|
103
|
+
let mut_onComplete: (() => void) | undefined = undefined;
|
|
104
|
+
|
|
105
|
+
const unsubscribe = vi.fn<() => void>(() => {
|
|
106
|
+
mut_onNext = undefined;
|
|
107
|
+
|
|
108
|
+
mut_onComplete = undefined;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
subscribable: {
|
|
113
|
+
subscribe: (onNext, _onError, onComplete) => {
|
|
114
|
+
mut_onNext = onNext;
|
|
115
|
+
|
|
116
|
+
mut_onComplete = onComplete;
|
|
117
|
+
|
|
118
|
+
return { unsubscribe };
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
emit: (value) => {
|
|
122
|
+
mut_onNext?.(value);
|
|
123
|
+
},
|
|
124
|
+
completeSource: () => {
|
|
125
|
+
mut_onComplete?.();
|
|
126
|
+
},
|
|
127
|
+
unsubscribe,
|
|
128
|
+
};
|
|
129
|
+
};
|