synstate 6.2.1 → 7.0.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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/core/class/child-observable-class.d.mts +16 -2
  3. package/dist/core/class/child-observable-class.d.mts.map +1 -1
  4. package/dist/core/class/child-observable-class.mjs +37 -4
  5. package/dist/core/class/child-observable-class.mjs.map +1 -1
  6. package/dist/core/class/create-child-observable.d.mts +1 -1
  7. package/dist/core/class/create-child-observable.d.mts.map +1 -1
  8. package/dist/core/class/create-child-observable.mjs +6 -8
  9. package/dist/core/class/create-child-observable.mjs.map +1 -1
  10. package/dist/core/class/create-root-observable.d.mts +1 -1
  11. package/dist/core/class/create-root-observable.d.mts.map +1 -1
  12. package/dist/core/class/create-root-observable.mjs +1 -5
  13. package/dist/core/class/create-root-observable.mjs.map +1 -1
  14. package/dist/core/class/propagation-state.mjs +1 -1
  15. package/dist/core/class/propagation-state.mjs.map +1 -1
  16. package/dist/core/combine/derive.d.mts.map +1 -1
  17. package/dist/core/combine/derive.mjs +2 -1
  18. package/dist/core/combine/derive.mjs.map +1 -1
  19. package/dist/core/combine/zip.d.mts.map +1 -1
  20. package/dist/core/combine/zip.mjs +5 -2
  21. package/dist/core/combine/zip.mjs.map +1 -1
  22. package/dist/core/create/counter.mjs +2 -2
  23. package/dist/core/create/counter.mjs.map +1 -1
  24. package/dist/core/create/from-abortable-promise.d.mts.map +1 -1
  25. package/dist/core/create/from-abortable-promise.mjs +10 -3
  26. package/dist/core/create/from-abortable-promise.mjs.map +1 -1
  27. package/dist/core/create/from-promise.d.mts.map +1 -1
  28. package/dist/core/create/from-promise.mjs +10 -3
  29. package/dist/core/create/from-promise.mjs.map +1 -1
  30. package/dist/core/create/source.d.mts.map +1 -1
  31. package/dist/core/create/source.mjs +2 -1
  32. package/dist/core/create/source.mjs.map +1 -1
  33. package/dist/core/operators/filter.d.mts +1 -1
  34. package/dist/core/operators/filter.d.mts.map +1 -1
  35. package/dist/core/operators/filter.mjs +2 -3
  36. package/dist/core/operators/filter.mjs.map +1 -1
  37. package/dist/core/operators/merge-map.d.mts +6 -2
  38. package/dist/core/operators/merge-map.d.mts.map +1 -1
  39. package/dist/core/operators/merge-map.mjs +47 -16
  40. package/dist/core/operators/merge-map.mjs.map +1 -1
  41. package/dist/core/operators/skip-while.d.mts +1 -1
  42. package/dist/core/operators/skip-while.d.mts.map +1 -1
  43. package/dist/core/operators/skip-while.mjs +9 -6
  44. package/dist/core/operators/skip-while.mjs.map +1 -1
  45. package/dist/core/operators/switch-map.d.mts +4 -0
  46. package/dist/core/operators/switch-map.d.mts.map +1 -1
  47. package/dist/core/operators/switch-map.mjs +16 -6
  48. package/dist/core/operators/switch-map.mjs.map +1 -1
  49. package/dist/core/operators/take-while.d.mts +2 -2
  50. package/dist/core/operators/take-while.d.mts.map +1 -1
  51. package/dist/core/operators/take-while.mjs +10 -8
  52. package/dist/core/operators/take-while.mjs.map +1 -1
  53. package/dist/core/operators/throttle.mjs +3 -2
  54. package/dist/core/operators/throttle.mjs.map +1 -1
  55. package/dist/core/operators/with-buffered-from.d.mts.map +1 -1
  56. package/dist/core/operators/with-buffered-from.mjs +13 -3
  57. package/dist/core/operators/with-buffered-from.mjs.map +1 -1
  58. package/dist/core/predefined/operators/attach-index.d.mts +1 -1
  59. package/dist/core/predefined/operators/attach-index.d.mts.map +1 -1
  60. package/dist/core/predefined/operators/attach-index.mjs +2 -3
  61. package/dist/core/predefined/operators/attach-index.mjs.map +1 -1
  62. package/dist/core/predefined/operators/skip.d.mts.map +1 -1
  63. package/dist/core/predefined/operators/skip.mjs +2 -2
  64. package/dist/core/predefined/operators/skip.mjs.map +1 -1
  65. package/dist/core/utils/index.d.mts +1 -0
  66. package/dist/core/utils/index.d.mts.map +1 -1
  67. package/dist/core/utils/index.mjs +1 -0
  68. package/dist/core/utils/index.mjs.map +1 -1
  69. package/dist/core/utils/rethrow-out-of-promise.d.mts +10 -0
  70. package/dist/core/utils/rethrow-out-of-promise.d.mts.map +1 -0
  71. package/dist/core/utils/rethrow-out-of-promise.mjs +17 -0
  72. package/dist/core/utils/rethrow-out-of-promise.mjs.map +1 -0
  73. package/package.json +12 -11
  74. package/src/core/class/child-observable-class.mts +45 -3
  75. package/src/core/class/create-child-observable.mts +8 -5
  76. package/src/core/class/create-root-observable.mts +2 -1
  77. package/src/core/class/propagation-state.mts +1 -1
  78. package/src/core/class/write-during-propagation.test.mts +75 -4
  79. package/src/core/combine/derive.mts +2 -1
  80. package/src/core/combine/zip.mts +5 -2
  81. package/src/core/create/counter.mts +2 -2
  82. package/src/core/create/from-abortable-promise.mts +33 -23
  83. package/src/core/create/from-promise.mts +28 -18
  84. package/src/core/create/from-promise.test.mts +97 -0
  85. package/src/core/create/source.mts +2 -1
  86. package/src/core/operators/filter.mts +2 -3
  87. package/src/core/operators/flat-map-initial-value.test.mts +269 -0
  88. package/src/core/operators/flat-map-sync-inner.test.mts +4 -2
  89. package/src/core/operators/inner-ownership.test.mts +145 -2
  90. package/src/core/operators/merge-map.mts +56 -26
  91. package/src/core/operators/skip-while.mts +12 -8
  92. package/src/core/operators/skip-while.test.mts +59 -0
  93. package/src/core/operators/switch-map.mts +21 -10
  94. package/src/core/operators/take-while.mts +15 -17
  95. package/src/core/operators/take-while.test.mts +51 -0
  96. package/src/core/operators/throttle.mts +4 -3
  97. package/src/core/operators/with-buffered-from.mts +16 -3
  98. package/src/core/operators/with-buffered-from.test.mts +27 -0
  99. package/src/core/predefined/operators/attach-index.mts +2 -3
  100. package/src/core/predefined/operators/skip.mts +2 -2
  101. package/src/core/utils/index.mts +1 -0
  102. package/src/core/utils/rethrow-out-of-promise.mts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synstate",
3
- "version": "6.2.1",
3
+ "version": "7.0.0",
4
4
  "description": "Type-safe State Management Library for TypeScript/JavaScript",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,21 +29,22 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "dependencies": {
32
- "ts-data-forge": "^14.10.2",
33
- "ts-std-forge": "^0.5.2",
32
+ "ts-data-forge": "^14.11.0",
33
+ "ts-std-forge": "^0.5.3",
34
34
  "ts-type-forge": "^9.2.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@preact/signals-core": "1.14.4",
38
38
  "@reduxjs/toolkit": "2.13.0",
39
- "@sumi-lang/cli": "0.1.5",
39
+ "@sumi-lang/cli": "0.1.6",
40
40
  "@types/react": "19.3.0",
41
41
  "@vitest/browser-playwright": "5.0.5",
42
42
  "argparse": "3.0.3",
43
- "eslint-config-typed": "7.3.1",
44
- "eslint-plugin-ts-data-forge": "0.10.8",
45
- "eslint-plugin-ts-fortress": "1.3.12",
46
- "eslint-plugin-ts-type-forge": "0.8.17",
43
+ "eslint": "10.13.0",
44
+ "eslint-config-typed": "7.4.0",
45
+ "eslint-plugin-ts-data-forge": "0.10.9",
46
+ "eslint-plugin-ts-fortress": "1.3.13",
47
+ "eslint-plugin-ts-type-forge": "0.8.18",
47
48
  "jotai": "3.0.1",
48
49
  "mobx": "7.1.0",
49
50
  "playwright": "1.64.1",
@@ -53,9 +54,9 @@
53
54
  "react": "19.3.1",
54
55
  "rxjs": "7.8.2",
55
56
  "solid-js": "1.9.17",
56
- "synstate-react-hooks": "6.2.1",
57
- "synstate-react-hooks-compat": "6.2.1",
58
- "ts-repo-utils": "10.7.1",
57
+ "synstate-react-hooks": "7.0.0",
58
+ "synstate-react-hooks-compat": "7.0.0",
59
+ "ts-repo-utils": "10.7.2",
59
60
  "valtio": "2.3.2",
60
61
  "zustand": "5.0.15"
61
62
  },
@@ -2,7 +2,7 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { hasKey, isRecord, type Optional, type Some } from 'ts-data-forge';
5
+ import { hasKey, isRecord, Optional, type Some } from 'ts-data-forge';
6
6
  import { panic } from 'ts-std-forge';
7
7
  import type { MutableSet } from 'ts-type-forge';
8
8
  import type {
@@ -20,7 +20,7 @@ import type {
20
20
  WithInitialValueOperator,
21
21
  Wrap,
22
22
  } from '../types/index.mjs';
23
- import { maxDepth } from '../utils/index.mjs';
23
+ import { issueUnqueuedToken, maxDepth } from '../utils/index.mjs';
24
24
  import { ManagerObservableClass } from './manager-observable-class.mjs';
25
25
  import { ObservableBaseClass } from './observable-base-class.mjs';
26
26
  import { toNode, type ChildNode } from './observable-node.mjs';
@@ -198,6 +198,45 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
198
198
  });
199
199
  }
200
200
 
201
+ /**
202
+ * `attach`, then projects the value `parent` holds when `switchMap` /
203
+ * `mergeMap` is created, as `map` and `filter` start from it; a completed
204
+ * parent is not projected. Nothing observes the operator yet, so a value the
205
+ * inner observable emits while it is subscribed is only stored: the token is
206
+ * one no update is queued with.
207
+ *
208
+ * If the projection throws, the operator is disposed, which disposes what the
209
+ * projection made and takes the operator off its parent, and the exception
210
+ * goes on to the caller of `pipe`.
211
+ */
212
+ protected attachProjectingHeldValue<V>(
213
+ parent: Observable<V>,
214
+ project: (value: V, updateToken: UpdateToken) => void,
215
+ ): void {
216
+ this.attach();
217
+
218
+ const sn = parent.getSnapshot();
219
+
220
+ if (this.isCompleted || Optional.isNone(sn)) {
221
+ return;
222
+ }
223
+
224
+ // A flag and `finally` rather than `catch` and rethrow: Sumi bans
225
+ // `try..catch`, and the exception is never caught on its way to `pipe`.
226
+ let mut_projected = false;
227
+
228
+ // @sumi-expect-error exceptions/no-try
229
+ try {
230
+ project(sn.value, issueUnqueuedToken());
231
+
232
+ mut_projected = true;
233
+ } finally {
234
+ if (!mut_projected) {
235
+ this.dispose();
236
+ }
237
+ }
238
+ }
239
+
201
240
  /**
202
241
  * Subscribes to the inner observable that the projection of `switchMap` /
203
242
  * `mergeMap` returned.
@@ -212,10 +251,13 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
212
251
  * glitch), and would emit twice.
213
252
  *
214
253
  * Values the inner observable emits later start updates of their own.
254
+ * `onComplete` is called when it completes, which may be while it is being
255
+ * subscribed, before this returns.
215
256
  */
216
257
  protected subscribeInner(
217
258
  inner: Observable<A>,
218
259
  updateToken: UpdateToken,
260
+ onComplete?: () => void,
219
261
  ): Subscription {
220
262
  let mut_isSubscribing = true;
221
263
 
@@ -225,7 +267,7 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
225
267
  } else {
226
268
  this.startUpdate(value);
227
269
  }
228
- });
270
+ }, onComplete);
229
271
 
230
272
  mut_isSubscribing = false;
231
273
 
@@ -2,7 +2,8 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { Arr, type Optional, type Some } from 'ts-data-forge';
5
+ import type { Optional, Some } from 'ts-data-forge';
6
+ import * as Arr from 'ts-data-forge/array';
6
7
  import { unreachable } from 'ts-std-forge';
7
8
  import type {
8
9
  AsyncChildObservable,
@@ -300,7 +301,9 @@ class CustomAsyncChildObservableClass<
300
301
  const P extends NonEmptyUnknownList,
301
302
  > extends AsyncChildObservableClass<A, P> {
302
303
  #mut_teardowns: readonly (() => void)[] | undefined;
303
- readonly #hooks: AsyncChildObservableHooks;
304
+ // Undefined until `init` returns, which may complete the observable first;
305
+ // `complete` then calls `flush` before there is a hook to call.
306
+ readonly #hooks: AsyncChildObservableHooks | undefined;
304
307
 
305
308
  constructor(
306
309
  config: ChildObservableConfig<A, P>,
@@ -344,15 +347,15 @@ class CustomAsyncChildObservableClass<
344
347
  }
345
348
 
346
349
  override tryUpdate(updateToken: UpdateToken): void {
347
- this.#hooks.tryUpdate(updateToken);
350
+ this.#hooks?.tryUpdate(updateToken);
348
351
  }
349
352
 
350
353
  override canEmitNoMore(): boolean {
351
- return this.#hooks.canEmitNoMore?.() === true;
354
+ return this.#hooks?.canEmitNoMore?.() === true;
352
355
  }
353
356
 
354
357
  protected override flush(): void {
355
- this.#hooks.flush?.();
358
+ this.#hooks?.flush?.();
356
359
  }
357
360
 
358
361
  protected override teardown(): void {
@@ -2,7 +2,8 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { Arr, type Optional } from 'ts-data-forge';
5
+ import type { Optional } from 'ts-data-forge';
6
+ import * as Arr from 'ts-data-forge/array';
6
7
  import type { ReadonlyRecord } from 'ts-type-forge';
7
8
  import type { RootObservable } from '../types/index.mjs';
8
9
  import { RootObservableClass } from './root-observable-class.mjs';
@@ -11,7 +11,7 @@
11
11
  * costs about twice as much per access as a module binding, on a path every
12
12
  * update takes.
13
13
  */
14
- import { Arr } from 'ts-data-forge';
14
+ import * as Arr from 'ts-data-forge/array';
15
15
  import { panic } from 'ts-std-forge';
16
16
  import type { MutableSet } from 'ts-type-forge';
17
17
  import type { UpdateToken } from '../types/index.mjs';
@@ -4,7 +4,12 @@ import type { FixedLengthTuple } from 'ts-type-forge';
4
4
  import { createState } from '../../utils/index.mjs';
5
5
  import { combine } from '../combine/index.mjs';
6
6
  import { fromSubscribable, source } from '../create/index.mjs';
7
- import { map, switchMap } from '../operators/index.mjs';
7
+ import {
8
+ map,
9
+ switchMap,
10
+ throttle,
11
+ withBufferedFrom,
12
+ } from '../operators/index.mjs';
8
13
  import { writeDuringPropagationWarning } from './root-observable-class.mjs';
9
14
 
10
15
  describe('a write to a root observable while an update is propagating (#2336)', () => {
@@ -156,7 +161,8 @@ describe('a write to a root observable while an update is propagating (#2336)',
156
161
 
157
162
  assert.deepStrictEqual(warn, []);
158
163
 
159
- assert.deepStrictEqual(mut_history, [10]);
164
+ // 0 from the value `outer$` held when `switchMap` was created.
165
+ assert.deepStrictEqual(mut_history, [0, 10]);
160
166
  });
161
167
 
162
168
  test('does not warn when a root created during propagation emits while it is created', async () => {
@@ -188,7 +194,8 @@ describe('a write to a root observable while an update is propagating (#2336)',
188
194
 
189
195
  assert.deepStrictEqual(warn, []);
190
196
 
191
- assert.deepStrictEqual(mut_history, [10]);
197
+ // 0 from the value `outer$` held when `switchMap` was created.
198
+ assert.deepStrictEqual(mut_history, [0, 10]);
192
199
  });
193
200
 
194
201
  test('a subscriber that throws does not leave the propagation flag set', async () => {
@@ -214,12 +221,76 @@ describe('a write to a root observable while an update is propagating (#2336)',
214
221
  });
215
222
  });
216
223
 
224
+ describe('a stateful operator updates its state before setNext, which a write from a subscriber re-enters', () => {
225
+ test('withBufferedFrom keeps a value written to the buffered observable for the next batch', async () => {
226
+ const [data$, setData] = createState('a');
227
+
228
+ const [trigger$, setTrigger] = createState(0);
229
+
230
+ const result$ = trigger$.pipe(withBufferedFrom(data$));
231
+
232
+ const mut_history: (readonly [number, readonly string[]])[] = [];
233
+
234
+ result$.subscribe((v) => {
235
+ mut_history.push(v);
236
+
237
+ if (v[0] === 1) {
238
+ setData('b');
239
+ }
240
+ });
241
+
242
+ await captureWarnings(() => {
243
+ setTrigger(1);
244
+ });
245
+
246
+ setTrigger(2);
247
+
248
+ assert.deepStrictEqual(mut_history, [
249
+ [0, ['a']],
250
+ [1, []],
251
+ [2, ['b']],
252
+ ]);
253
+ });
254
+
255
+ test('throttle drops a value a subscriber writes to the parent inside the window, and keeps one timer', async () => {
256
+ vi.useFakeTimers();
257
+
258
+ onTestFinished(() => {
259
+ vi.useRealTimers();
260
+ });
261
+
262
+ const [src$, setSrc] = createState(0);
263
+
264
+ const throttled$ = src$.pipe(throttle(1000));
265
+
266
+ const mut_history: number[] = [];
267
+
268
+ throttled$.subscribe((v) => {
269
+ mut_history.push(v);
270
+
271
+ if (v === 1) {
272
+ setSrc(2);
273
+ }
274
+ });
275
+
276
+ await captureWarnings(() => {
277
+ setSrc(1);
278
+ });
279
+
280
+ assert.deepStrictEqual(mut_history, [0, 1]);
281
+
282
+ throttled$.complete();
283
+
284
+ assert.strictEqual(vi.getTimerCount(), 0);
285
+ });
286
+ });
287
+
217
288
  /**
218
289
  * Runs `run` with `console.warn` replaced by a mock, and returns the messages
219
290
  * it was called with.
220
291
  */
221
292
  const captureWarnings = async (
222
- run: () => Promise<void> | void,
293
+ run: () => Promise<void> | undefined,
223
294
  ): Promise<readonly unknown[]> => {
224
295
  const warn = vi.fn();
225
296
 
@@ -5,7 +5,8 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { Arr, Optional } from 'ts-data-forge';
8
+ import { Optional } from 'ts-data-forge';
9
+ import * as Arr from 'ts-data-forge/array';
9
10
  import type { NonEmptyTuple } from 'ts-type-forge';
10
11
  import { SyncChildObservableClass } from '../class/index.mjs';
11
12
  import type {
@@ -5,7 +5,8 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { Arr, Optional, createQueue, expectType } from 'ts-data-forge';
8
+ import { Optional, createQueue, expectType } from 'ts-data-forge';
9
+ import * as Arr from 'ts-data-forge/array';
9
10
  import type { NonEmptyTuple, Tuple } from 'ts-type-forge';
10
11
  import { SyncChildObservableClass } from '../class/index.mjs';
11
12
  import { source } from '../create/index.mjs';
@@ -150,8 +151,10 @@ class ZipObservableClass<const A extends NonEmptyUnknownList>
150
151
 
151
152
  this.setNext(nextValue, updateToken);
152
153
 
154
+ // After the update has reached what is derived from it, as in `take`
155
+ // (#2371).
153
156
  if (this.canEmitNoMore()) {
154
- this.complete();
157
+ this.completeAfterPropagation();
155
158
  }
156
159
  }
157
160
 
@@ -5,7 +5,7 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { asSafeUint, Optional, SafeUint } from 'ts-data-forge';
8
+ import { asSafeUint, Optional, type SafeUint } from 'ts-data-forge';
9
9
  import type { PositiveSafeIntWithSmallInt } from 'ts-type-forge';
10
10
  import { RootObservableClass } from '../class/index.mjs';
11
11
  import type { CounterObservable, TimerId } from '../types/index.mjs';
@@ -154,7 +154,7 @@ class CounterObservableClass
154
154
  }
155
155
 
156
156
  this.#mut_timerId = setInterval(() => {
157
- this.#mut_counter = SafeUint.add(1, this.#mut_counter);
157
+ this.#mut_counter = asSafeUint(this.#mut_counter + 1);
158
158
 
159
159
  this.#emit(this.#mut_counter);
160
160
  }, this.#intervalMilliSeconds);
@@ -8,6 +8,7 @@
8
8
  import { Optional, Result } from 'ts-data-forge';
9
9
  import { RootObservableClass } from '../class/index.mjs';
10
10
  import type { FromPromiseObservable } from '../types/index.mjs';
11
+ import { rethrowOutOfPromise } from '../utils/index.mjs';
11
12
 
12
13
  /**
13
14
  * Creates an observable from a Promise factory that receives an `AbortSignal`.
@@ -96,35 +97,44 @@ class FromAbortablePromiseObservableClass<A, E = unknown>
96
97
 
97
98
  const promise = factory(this.#abortController.signal);
98
99
 
100
+ // `onRejected` beside `onFulfilled`, so that only the promise's own
101
+ // rejection becomes an Err: what a subscriber throws on the Ok value is not
102
+ // the promise's (#2931). A `.catch` after `.then` would take both, and is
103
+ // also a step later.
99
104
  promise
100
- .then((value) => {
101
- if (this.isCompleted) {
102
- return;
103
- }
105
+ // eslint-disable-next-line unicorn/prefer-then-catch -- its suggestion, `.then(onFulfilled).catch(onRejected)`, would take what a subscriber throws too
106
+ .then(
107
+ (value) => {
108
+ if (this.isCompleted) {
109
+ return;
110
+ }
104
111
 
105
- this.startUpdate(Result.ok(value));
106
- })
107
- .catch((error: unknown) => {
108
- if (this.isCompleted) {
109
- return;
110
- }
112
+ this.startUpdate(Result.ok(value));
113
+ },
114
+ (error: unknown) => {
115
+ if (this.isCompleted) {
116
+ return;
117
+ }
111
118
 
112
- // Silently ignore AbortError — it means the observable was
113
- // intentionally completed (e.g., by switchMap).
114
- if (error instanceof DOMException && error.name === 'AbortError') {
115
- return;
116
- }
119
+ // Silently ignore AbortError — it means the observable was
120
+ // intentionally completed (e.g., by switchMap).
121
+ if (error instanceof DOMException && error.name === 'AbortError') {
122
+ return;
123
+ }
117
124
 
118
- this.startUpdate(
119
- Result.err(
120
- // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
121
- error as E,
122
- ),
123
- );
124
- })
125
+ this.startUpdate(
126
+ Result.err(
127
+ // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
128
+ error as E,
129
+ ),
130
+ );
131
+ },
132
+ )
133
+ .catch(rethrowOutOfPromise)
125
134
  .finally(() => {
126
135
  this.complete();
127
- });
136
+ })
137
+ .catch(rethrowOutOfPromise);
128
138
  }
129
139
 
130
140
  protected override teardown(): void {
@@ -7,6 +7,7 @@
7
7
  import { Optional, Result } from 'ts-data-forge';
8
8
  import { RootObservableClass } from '../class/index.mjs';
9
9
  import type { FromPromiseObservable } from '../types/index.mjs';
10
+ import { rethrowOutOfPromise } from '../utils/index.mjs';
10
11
 
11
12
  /**
12
13
  * Creates an observable from a Promise.
@@ -66,28 +67,37 @@ class FromPromiseObservableClass<A, E = unknown>
66
67
  constructor(promise: Promise<A>) {
67
68
  super({ initialValue: Optional.none });
68
69
 
70
+ // `onRejected` beside `onFulfilled`, so that only the promise's own
71
+ // rejection becomes an Err: what a subscriber throws on the Ok value is not
72
+ // the promise's (#2931). A `.catch` after `.then` would take both, and is
73
+ // also a step later.
69
74
  promise
70
- .then((value) => {
71
- if (this.isCompleted) {
72
- return;
73
- }
75
+ // eslint-disable-next-line unicorn/prefer-then-catch -- its suggestion, `.then(onFulfilled).catch(onRejected)`, would take what a subscriber throws too
76
+ .then(
77
+ (value) => {
78
+ if (this.isCompleted) {
79
+ return;
80
+ }
74
81
 
75
- this.startUpdate(Result.ok(value));
76
- })
77
- .catch((error: unknown) => {
78
- if (this.isCompleted) {
79
- return;
80
- }
82
+ this.startUpdate(Result.ok(value));
83
+ },
84
+ (error: unknown) => {
85
+ if (this.isCompleted) {
86
+ return;
87
+ }
81
88
 
82
- this.startUpdate(
83
- Result.err(
84
- // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
85
- error as E,
86
- ),
87
- );
88
- })
89
+ this.startUpdate(
90
+ Result.err(
91
+ // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
92
+ error as E,
93
+ ),
94
+ );
95
+ },
96
+ )
97
+ .catch(rethrowOutOfPromise)
89
98
  .finally(() => {
90
99
  this.complete();
91
- });
100
+ })
101
+ .catch(rethrowOutOfPromise);
92
102
  }
93
103
  }
@@ -0,0 +1,97 @@
1
+ import { Optional, Result } from 'ts-data-forge';
2
+ import { panic } from 'ts-std-forge';
3
+ import { fromAbortablePromise } from './from-abortable-promise.mjs';
4
+ import { fromPromise } from './from-promise.mjs';
5
+
6
+ describe('fromPromise and fromAbortablePromise report only the promise’s own rejection as an Err (#2931)', () => {
7
+ test.each([
8
+ ['fromPromise', () => fromPromise(Promise.resolve(1))],
9
+ [
10
+ 'fromAbortablePromise',
11
+ () => fromAbortablePromise(() => Promise.resolve(1)),
12
+ ],
13
+ ] as const)(
14
+ '%s: a subscriber that throws on the Ok value gets no Err',
15
+ async (_, create) => {
16
+ vi.useFakeTimers();
17
+
18
+ onTestFinished(() => {
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ const mut_events: string[] = [];
23
+
24
+ const result$ = create();
25
+
26
+ result$.subscribe(
27
+ (r) => {
28
+ mut_events.push(Result.isOk(r) ? `ok ${r.value}` : 'err');
29
+
30
+ if (Result.isOk(r)) {
31
+ panic('bug in a subscriber');
32
+ }
33
+ },
34
+ () => {
35
+ mut_events.push('complete');
36
+ },
37
+ );
38
+
39
+ await settlePromises();
40
+
41
+ assert.deepStrictEqual(mut_events, ['ok 1', 'complete']);
42
+
43
+ // The subscriber's exception is not swallowed: it is thrown again from a
44
+ // task of its own.
45
+ expect(() => {
46
+ vi.runOnlyPendingTimers();
47
+ }).toThrow('bug in a subscriber');
48
+ },
49
+ );
50
+
51
+ const rejection = new Error('rejected');
52
+
53
+ test.each([
54
+ ['fromPromise', () => fromPromise(Promise.reject(rejection))],
55
+ [
56
+ 'fromAbortablePromise',
57
+ () => fromAbortablePromise(() => Promise.reject(rejection)),
58
+ ],
59
+ ] as const)('%s: a rejection is an Err', async (_, create) => {
60
+ const result$ = create();
61
+
62
+ const mut_events: string[] = [];
63
+
64
+ result$.subscribe(
65
+ (r) => {
66
+ mut_events.push(
67
+ Result.isErr(r) && r.value === rejection ? 'err rejected' : 'ok',
68
+ );
69
+ },
70
+ () => {
71
+ mut_events.push('complete');
72
+ },
73
+ );
74
+
75
+ await settlePromises();
76
+
77
+ assert.deepStrictEqual(mut_events, ['err rejected', 'complete']);
78
+ });
79
+
80
+ test('fromPromise holds the value by the time an await of the same promise resumes', async () => {
81
+ const promise = Promise.resolve(1);
82
+
83
+ const result$ = fromPromise(promise);
84
+
85
+ await promise;
86
+
87
+ assert.deepStrictEqual(result$.getSnapshot(), Optional.some(Result.ok(1)));
88
+ });
89
+ });
90
+
91
+ /** Lets the promise chains already started run to their end. */
92
+ const settlePromises = async (): Promise<void> => {
93
+ for (const _ of Array.from({ length: 10 })) {
94
+ // eslint-disable-next-line no-await-in-loop -- each await lets the chains take one more step
95
+ await Promise.resolve();
96
+ }
97
+ };
@@ -4,7 +4,8 @@
4
4
  // @sumi-expect-error-file classes/no-class
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
- import { Arr, Optional } from 'ts-data-forge';
7
+ import { Optional } from 'ts-data-forge';
8
+ import * as Arr from 'ts-data-forge/array';
8
9
  import { RootObservableClass } from '../class/index.mjs';
9
10
  import type {
10
11
  InitializedSourceObservable,
@@ -12,7 +12,7 @@ import {
12
12
  isString,
13
13
  Optional,
14
14
  pipe,
15
- SafeUint,
15
+ type SafeUint,
16
16
  } from 'ts-data-forge';
17
17
  import { SyncChildObservableClass } from '../class/index.mjs';
18
18
  import { source } from '../create/index.mjs';
@@ -132,8 +132,7 @@ class FilterObservableClass<A>
132
132
  return; // skip update
133
133
  }
134
134
 
135
- this.#mut_index =
136
- this.#mut_index === -1 ? asSafeUint(0) : SafeUint.add(1, this.#mut_index);
135
+ this.#mut_index = asSafeUint(this.#mut_index + 1);
137
136
 
138
137
  if (this.#predicate(sn.value, this.#mut_index)) {
139
138
  this.setNext(sn.value, updateToken);