synstate 3.3.0 → 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 +26 -8
- package/dist/core/base/create-child-observable.mjs.map +1 -1
- package/dist/core/base/create-observable-base.d.mts +2 -0
- package/dist/core/base/create-observable-base.d.mts.map +1 -1
- package/dist/core/base/create-observable-base.mjs +21 -3
- 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 +25 -2
- 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/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 +34 -6
- package/src/core/base/create-observable-base.mts +23 -3
- package/src/core/base/create-root-observable.mts +35 -1
- 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/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
|
@@ -57,6 +57,14 @@ type ChildObservableConfigBase<P extends NonEmptyUnknownList> = Readonly<{
|
|
|
57
57
|
* `override complete() { teardown(); super.complete(); }`.
|
|
58
58
|
*/
|
|
59
59
|
onComplete?: () => void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether this observable can emit nothing more although some parent is
|
|
63
|
+
* still live, asked whenever a parent completes. `true` completes it then,
|
|
64
|
+
* as all parents completing does (`zip` once a completed source's queue is
|
|
65
|
+
* empty).
|
|
66
|
+
*/
|
|
67
|
+
canEmitNoMore?: () => boolean;
|
|
60
68
|
}>;
|
|
61
69
|
|
|
62
70
|
export type ChildObservableConfig<
|
|
@@ -91,6 +99,7 @@ export const createSyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
91
99
|
depth = 1 + maxDepth(parents),
|
|
92
100
|
initialValue,
|
|
93
101
|
onComplete,
|
|
102
|
+
canEmitNoMore,
|
|
94
103
|
}: ChildObservableConfig<A, P>,
|
|
95
104
|
createTryUpdate: (
|
|
96
105
|
tools: SyncChildObservableTools<A>,
|
|
@@ -140,6 +149,7 @@ export const createSyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
tryCompleteChild({
|
|
152
|
+
canEmitNoMore,
|
|
143
153
|
hasSubscriber: handle.hasSubscriber(),
|
|
144
154
|
hasActiveChild: handle.hasActiveChild(),
|
|
145
155
|
autoComplete,
|
|
@@ -166,9 +176,16 @@ export const createSyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
166
176
|
extra: { parents },
|
|
167
177
|
});
|
|
168
178
|
|
|
169
|
-
|
|
179
|
+
// Completed parents never update again, so a child created under them is
|
|
180
|
+
// complete from the start; registering it would only leave a reference in
|
|
181
|
+
// each parent (#2248).
|
|
182
|
+
if (parents.every((p) => p.isCompleted)) {
|
|
183
|
+
dispose();
|
|
184
|
+
} else {
|
|
185
|
+
registerChild(observable, parents);
|
|
170
186
|
|
|
171
|
-
|
|
187
|
+
mut_self = observable;
|
|
188
|
+
}
|
|
172
189
|
|
|
173
190
|
return observable;
|
|
174
191
|
};
|
|
@@ -210,6 +227,7 @@ export const createAsyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
210
227
|
depth = 1 + maxDepth(parents),
|
|
211
228
|
initialValue,
|
|
212
229
|
onComplete,
|
|
230
|
+
canEmitNoMore,
|
|
213
231
|
}: ChildObservableConfig<A, P>,
|
|
214
232
|
createTryUpdate: (
|
|
215
233
|
tools: AsyncChildObservableTools<A>,
|
|
@@ -262,6 +280,7 @@ export const createAsyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
tryCompleteChild({
|
|
283
|
+
canEmitNoMore,
|
|
265
284
|
hasSubscriber: handle.hasSubscriber(),
|
|
266
285
|
hasActiveChild: handle.hasActiveChild(),
|
|
267
286
|
autoComplete,
|
|
@@ -289,9 +308,16 @@ export const createAsyncChildObservable = <A, P extends NonEmptyUnknownList>(
|
|
|
289
308
|
extra: { parents, addDescendant, deleteDescendant },
|
|
290
309
|
});
|
|
291
310
|
|
|
292
|
-
|
|
311
|
+
// Completed parents never update again, so a child created under them is
|
|
312
|
+
// complete from the start; registering it would only leave a reference in
|
|
313
|
+
// each parent (#2248).
|
|
314
|
+
if (parents.every((p) => p.isCompleted)) {
|
|
315
|
+
dispose();
|
|
316
|
+
} else {
|
|
317
|
+
registerChild(observable, parents);
|
|
293
318
|
|
|
294
|
-
|
|
319
|
+
mut_self = observable;
|
|
320
|
+
}
|
|
295
321
|
|
|
296
322
|
return observable;
|
|
297
323
|
};
|
|
@@ -426,20 +452,22 @@ const unregisterChild = <A,>(
|
|
|
426
452
|
};
|
|
427
453
|
|
|
428
454
|
const tryCompleteChild = <A,>({
|
|
455
|
+
canEmitNoMore,
|
|
429
456
|
hasSubscriber,
|
|
430
457
|
hasActiveChild,
|
|
431
458
|
autoComplete,
|
|
432
459
|
parents,
|
|
433
460
|
complete,
|
|
434
461
|
}: Readonly<{
|
|
462
|
+
canEmitNoMore: (() => boolean) | undefined;
|
|
435
463
|
hasSubscriber: boolean;
|
|
436
464
|
hasActiveChild: boolean;
|
|
437
465
|
autoComplete: boolean;
|
|
438
466
|
parents: ChildObservable<A>['parents'];
|
|
439
467
|
complete: () => void;
|
|
440
468
|
}>): void => {
|
|
441
|
-
// If there is no working parent node
|
|
442
|
-
if (parents.every((r) => r.isCompleted)) {
|
|
469
|
+
// If there is no working parent node, or no value can come from the live ones
|
|
470
|
+
if (parents.every((r) => r.isCompleted) || canEmitNoMore?.() === true) {
|
|
443
471
|
complete();
|
|
444
472
|
|
|
445
473
|
return;
|
|
@@ -285,6 +285,16 @@ export const createObservableBaseHandle = <A,>(
|
|
|
285
285
|
};
|
|
286
286
|
};
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* How many `startUpdate` calls are running, across every observable. A root
|
|
290
|
+
* written to while this is non-zero is written to from inside a subscriber or
|
|
291
|
+
* a projection, which {@link isPropagating} lets it detect (#2336).
|
|
292
|
+
*/
|
|
293
|
+
const mut_propagation = { depth: 0 };
|
|
294
|
+
|
|
295
|
+
/** Whether some observable's update is propagating right now. */
|
|
296
|
+
export const isPropagating = (): boolean => mut_propagation.depth > 0;
|
|
297
|
+
|
|
288
298
|
/**
|
|
289
299
|
* The extra members every manager observable — a root or an async child — owns:
|
|
290
300
|
* the depth-ordered descendant list and the update propagation that walks it.
|
|
@@ -389,10 +399,20 @@ export const createManagerObservableParts = <A,>(
|
|
|
389
399
|
const startUpdate = (nextValue: A): void => {
|
|
390
400
|
const updateToken = issueUpdateToken();
|
|
391
401
|
|
|
392
|
-
|
|
402
|
+
mut_propagation.depth += 1;
|
|
403
|
+
|
|
404
|
+
// A subscriber or projection may throw, and the depth has to come back
|
|
405
|
+
// down regardless, or every later write would warn. The exception itself
|
|
406
|
+
// is not caught: it reaches the caller of `next` as it always has.
|
|
407
|
+
// @sumi-expect-error exceptions/no-try
|
|
408
|
+
try {
|
|
409
|
+
handle.setNext(nextValue, updateToken);
|
|
393
410
|
|
|
394
|
-
|
|
395
|
-
|
|
411
|
+
for (const p of getPropagationOrder()) {
|
|
412
|
+
p.tryUpdate(updateToken);
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
mut_propagation.depth -= 1;
|
|
396
416
|
}
|
|
397
417
|
};
|
|
398
418
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createManagerObservableParts,
|
|
7
7
|
createObservableBaseHandle,
|
|
8
8
|
createTryUpdateNotImplemented,
|
|
9
|
+
isPropagating,
|
|
9
10
|
} from './create-observable-base.mjs';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -17,6 +18,8 @@ export type RootObservableTools<A> = Readonly<{
|
|
|
17
18
|
/** Emits `nextValue` and propagates the update to all descendants. */
|
|
18
19
|
startUpdate: (nextValue: A) => void;
|
|
19
20
|
|
|
21
|
+
getSnapshot: () => Optional<A>;
|
|
22
|
+
|
|
20
23
|
isCompleted: () => boolean;
|
|
21
24
|
|
|
22
25
|
/** Completes this observable (leaf teardown included). */
|
|
@@ -78,12 +81,33 @@ export const createRootObservable = <
|
|
|
78
81
|
}
|
|
79
82
|
};
|
|
80
83
|
|
|
84
|
+
// A root created while an update propagates (inside a `switchMap`
|
|
85
|
+
// projection, say) may emit synchronously while `init` runs, as
|
|
86
|
+
// `fromSubscribable` does for a synchronous source. Nothing is derived from
|
|
87
|
+
// it yet, so that is its initial value rather than a write (#2336).
|
|
88
|
+
let mut_isInitializing = true;
|
|
89
|
+
|
|
90
|
+
let mut_hasWarned = false;
|
|
91
|
+
|
|
92
|
+
const startUpdateWithWarning = (nextValue: A): void => {
|
|
93
|
+
if (!mut_isInitializing && !mut_hasWarned && isPropagating()) {
|
|
94
|
+
mut_hasWarned = true;
|
|
95
|
+
|
|
96
|
+
console.warn(writeDuringPropagationWarning);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
startUpdate(nextValue);
|
|
100
|
+
};
|
|
101
|
+
|
|
81
102
|
const extension: Extension = init({
|
|
82
|
-
startUpdate,
|
|
103
|
+
startUpdate: startUpdateWithWarning,
|
|
104
|
+
getSnapshot: handle.getSnapshot,
|
|
83
105
|
isCompleted: handle.isCompleted,
|
|
84
106
|
complete,
|
|
85
107
|
});
|
|
86
108
|
|
|
109
|
+
mut_isInitializing = false;
|
|
110
|
+
|
|
87
111
|
return assembleObservable({
|
|
88
112
|
kind: 'root',
|
|
89
113
|
depth: 0,
|
|
@@ -102,6 +126,16 @@ export const createRootObservable = <
|
|
|
102
126
|
});
|
|
103
127
|
};
|
|
104
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Shown once per root observable, the first time it is written to (`next`,
|
|
131
|
+
* `setState`, `dispatch`, …) while another update is still propagating —
|
|
132
|
+
* from a subscriber or a projection. The write still propagates immediately,
|
|
133
|
+
* as it always has; the warning is there because the pattern is wrong, not
|
|
134
|
+
* because anything was refused.
|
|
135
|
+
*/
|
|
136
|
+
export const writeDuringPropagationWarning =
|
|
137
|
+
'synstate: an observable was written to (next / setState / dispatch) while an update was propagating, so subscribers can see intermediate states. Derive the value with map / combine instead of writing from a subscriber or a projection.';
|
|
138
|
+
|
|
105
139
|
if (import.meta.vitest !== undefined) {
|
|
106
140
|
test('isRootObservable', () => {
|
|
107
141
|
assert.isTrue(
|
|
@@ -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
|
}),
|