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.
Files changed (91) 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 +49 -11
  5. package/dist/core/base/create-child-observable.mjs.map +1 -1
  6. package/dist/core/base/create-observable-base.d.mts +5 -1
  7. package/dist/core/base/create-observable-base.d.mts.map +1 -1
  8. package/dist/core/base/create-observable-base.mjs +24 -4
  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 +32 -3
  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/core/types/observable.d.mts +17 -0
  54. package/dist/core/types/observable.d.mts.map +1 -1
  55. package/dist/core/types/observable.mjs.map +1 -1
  56. package/dist/utils/create-event-emitter.d.mts +2 -2
  57. package/dist/utils/create-reducer.d.mts +10 -1
  58. package/dist/utils/create-reducer.d.mts.map +1 -1
  59. package/dist/utils/create-reducer.mjs +13 -5
  60. package/dist/utils/create-reducer.mjs.map +1 -1
  61. package/dist/utils/create-state.d.mts +7 -0
  62. package/dist/utils/create-state.d.mts.map +1 -1
  63. package/dist/utils/create-state.mjs +7 -0
  64. package/dist/utils/create-state.mjs.map +1 -1
  65. package/package.json +4 -4
  66. package/src/core/base/create-child-observable.mts +68 -10
  67. package/src/core/base/create-observable-base.mts +31 -3
  68. package/src/core/base/create-root-observable.mts +43 -2
  69. package/src/core/base/write-during-propagation.test.mts +234 -0
  70. package/src/core/combine/merge.mts +0 -3
  71. package/src/core/combine/zip.mts +36 -5
  72. package/src/core/create/counter.mts +27 -6
  73. package/src/core/create/counter.test.mts +82 -0
  74. package/src/core/create/from-subscribable.mts +33 -5
  75. package/src/core/create/from-subscribable.test.mts +129 -0
  76. package/src/core/create/source.mts +18 -3
  77. package/src/core/create/source.test.mts +41 -0
  78. package/src/core/operators/merge-map.mts +0 -3
  79. package/src/core/operators/skip-until.mts +21 -9
  80. package/src/core/operators/switch-map.mts +0 -3
  81. package/src/core/operators/take-until-skip-until-teardown.test.mts +127 -0
  82. package/src/core/operators/take-until.mts +15 -1
  83. package/src/core/operators/with-buffered-from.mts +15 -3
  84. package/src/core/operators/with-buffered-from.test.mts +56 -0
  85. package/src/core/types/observable-family.mts +5 -1
  86. package/src/core/types/observable.mts +19 -0
  87. package/src/utils/create-event-emitter.mts +2 -2
  88. package/src/utils/create-reducer.mts +14 -8
  89. package/src/utils/create-reducer.test.mts +12 -0
  90. package/src/utils/create-state.mts +7 -0
  91. 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,
@@ -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
  }),
@@ -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
- createRootObservable<Result<A, E>>(
70
- { initialValue: Optional.none },
71
- ({ startUpdate, complete }) => {
72
- subscribable.subscribe(
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
+ };