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.
Files changed (87) hide show
  1. package/README.md +2 -2
  2. package/dist/core/base/create-child-observable.d.mts +9 -2
  3. package/dist/core/base/create-child-observable.d.mts.map +1 -1
  4. package/dist/core/base/create-child-observable.mjs +26 -8
  5. package/dist/core/base/create-child-observable.mjs.map +1 -1
  6. package/dist/core/base/create-observable-base.d.mts +2 -0
  7. package/dist/core/base/create-observable-base.d.mts.map +1 -1
  8. package/dist/core/base/create-observable-base.mjs +21 -3
  9. package/dist/core/base/create-observable-base.mjs.map +1 -1
  10. package/dist/core/base/create-root-observable.d.mts +9 -0
  11. package/dist/core/base/create-root-observable.d.mts.map +1 -1
  12. package/dist/core/base/create-root-observable.mjs +25 -2
  13. package/dist/core/base/create-root-observable.mjs.map +1 -1
  14. package/dist/core/combine/merge.d.mts +0 -3
  15. package/dist/core/combine/merge.d.mts.map +1 -1
  16. package/dist/core/combine/merge.mjs +0 -3
  17. package/dist/core/combine/merge.mjs.map +1 -1
  18. package/dist/core/combine/zip.d.mts +4 -2
  19. package/dist/core/combine/zip.d.mts.map +1 -1
  20. package/dist/core/combine/zip.mjs +21 -3
  21. package/dist/core/combine/zip.mjs.map +1 -1
  22. package/dist/core/create/counter.d.mts +9 -3
  23. package/dist/core/create/counter.d.mts.map +1 -1
  24. package/dist/core/create/counter.mjs +16 -3
  25. package/dist/core/create/counter.mjs.map +1 -1
  26. package/dist/core/create/from-subscribable.d.mts.map +1 -1
  27. package/dist/core/create/from-subscribable.mjs +34 -11
  28. package/dist/core/create/from-subscribable.mjs.map +1 -1
  29. package/dist/core/create/source.d.mts +7 -0
  30. package/dist/core/create/source.d.mts.map +1 -1
  31. package/dist/core/create/source.mjs +6 -2
  32. package/dist/core/create/source.mjs.map +1 -1
  33. package/dist/core/operators/merge-map.d.mts +0 -3
  34. package/dist/core/operators/merge-map.d.mts.map +1 -1
  35. package/dist/core/operators/merge-map.mjs +0 -3
  36. package/dist/core/operators/merge-map.mjs.map +1 -1
  37. package/dist/core/operators/skip-until.d.mts.map +1 -1
  38. package/dist/core/operators/skip-until.mjs +15 -6
  39. package/dist/core/operators/skip-until.mjs.map +1 -1
  40. package/dist/core/operators/switch-map.d.mts +0 -3
  41. package/dist/core/operators/switch-map.d.mts.map +1 -1
  42. package/dist/core/operators/switch-map.mjs +0 -3
  43. package/dist/core/operators/switch-map.mjs.map +1 -1
  44. package/dist/core/operators/take-until.d.mts.map +1 -1
  45. package/dist/core/operators/take-until.mjs +13 -1
  46. package/dist/core/operators/take-until.mjs.map +1 -1
  47. package/dist/core/operators/with-buffered-from.d.mts.map +1 -1
  48. package/dist/core/operators/with-buffered-from.mjs +12 -3
  49. package/dist/core/operators/with-buffered-from.mjs.map +1 -1
  50. package/dist/core/types/observable-family.d.mts +5 -1
  51. package/dist/core/types/observable-family.d.mts.map +1 -1
  52. package/dist/core/types/observable-family.mjs.map +1 -1
  53. package/dist/utils/create-event-emitter.d.mts +2 -2
  54. package/dist/utils/create-reducer.d.mts +10 -1
  55. package/dist/utils/create-reducer.d.mts.map +1 -1
  56. package/dist/utils/create-reducer.mjs +13 -5
  57. package/dist/utils/create-reducer.mjs.map +1 -1
  58. package/dist/utils/create-state.d.mts +7 -0
  59. package/dist/utils/create-state.d.mts.map +1 -1
  60. package/dist/utils/create-state.mjs +7 -0
  61. package/dist/utils/create-state.mjs.map +1 -1
  62. package/package.json +4 -4
  63. package/src/core/base/create-child-observable.mts +34 -6
  64. package/src/core/base/create-observable-base.mts +23 -3
  65. package/src/core/base/create-root-observable.mts +35 -1
  66. package/src/core/base/write-during-propagation.test.mts +234 -0
  67. package/src/core/combine/merge.mts +0 -3
  68. package/src/core/combine/zip.mts +36 -5
  69. package/src/core/create/counter.mts +27 -6
  70. package/src/core/create/counter.test.mts +82 -0
  71. package/src/core/create/from-subscribable.mts +33 -5
  72. package/src/core/create/from-subscribable.test.mts +129 -0
  73. package/src/core/create/source.mts +18 -3
  74. package/src/core/create/source.test.mts +41 -0
  75. package/src/core/operators/merge-map.mts +0 -3
  76. package/src/core/operators/skip-until.mts +21 -9
  77. package/src/core/operators/switch-map.mts +0 -3
  78. package/src/core/operators/take-until-skip-until-teardown.test.mts +127 -0
  79. package/src/core/operators/take-until.mts +15 -1
  80. package/src/core/operators/with-buffered-from.mts +15 -3
  81. package/src/core/operators/with-buffered-from.test.mts +56 -0
  82. package/src/core/types/observable-family.mts +5 -1
  83. package/src/utils/create-event-emitter.mts +2 -2
  84. package/src/utils/create-reducer.mts +14 -8
  85. package/src/utils/create-reducer.test.mts +12 -0
  86. package/src/utils/create-state.mts +7 -0
  87. 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
- registerChild(observable, parents);
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
- mut_self = observable;
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
- registerChild(observable, parents);
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
- mut_self = observable;
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
- handle.setNext(nextValue, updateToken);
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
- for (const p of getPropagationOrder()) {
395
- p.tryUpdate(updateToken);
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,
@@ -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 when any source 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 when any source 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
- queues[index]?.enqueue(sn.value);
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.deepStrictEqual(valueHistory[0], 0);
53
+ * assert.strictEqual(valueHistory[0], 0);
49
54
  *
50
- * assert.deepStrictEqual(valueHistory[1], 1);
55
+ * assert.strictEqual(valueHistory[1], 1);
51
56
  *
52
- * assert.deepStrictEqual(valueHistory[2], 2);
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
- startUpdate(mut_counter);
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
- startUpdate(mut_counter);
131
+ emit(mut_counter);
111
132
  }, intervalMilliSeconds);
112
133
  },
113
134
  }),