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
@@ -43,6 +43,13 @@ import type {
43
43
  *
44
44
  * assert.deepStrictEqual(valueHistory, [1, 2, 3]);
45
45
  * ```
46
+ *
47
+ * @note Do not call `next` from a subscriber or a projection while another
48
+ * update is propagating: the write propagates immediately, so subscribers can
49
+ * see an intermediate state, and it logs a `console.warn` (once per
50
+ * observable). Derive the value from its source instead —
51
+ * `const b$ = a$.pipe(map((a) => a * 2))` rather than
52
+ * `a$.subscribe((a) => { setB(a * 2); })`.
46
53
  */
47
54
  export function source<const A>(
48
55
  initialValue: A,
@@ -51,19 +58,27 @@ export function source<const A>(
51
58
  export function source<const A>(): SourceObservable<A>;
52
59
 
53
60
  export function source<const A>(...args: readonly A[]): SourceObservable<A> {
54
- return createRootObservable<A, Readonly<{ next: (nextValue: A) => void }>>(
61
+ return createRootObservable<
62
+ A,
63
+ Readonly<{ next: (nextValue: A) => A | undefined }>
64
+ >(
55
65
  {
56
66
  initialValue: Arr.isNonEmpty(args)
57
67
  ? Optional.some(args[0])
58
68
  : Optional.none,
59
69
  },
60
- ({ startUpdate, isCompleted }) => ({
70
+ ({ startUpdate, getSnapshot, isCompleted }) => ({
71
+ // Returns the value held after the call: `nextValue`, or, once
72
+ // completed, the value already there — `undefined` only on a source
73
+ // that never had one, whose `next` is typed `void`.
61
74
  next: (nextValue: A) => {
62
75
  if (isCompleted()) {
63
- return;
76
+ return Optional.unwrap(getSnapshot());
64
77
  }
65
78
 
66
79
  startUpdate(nextValue);
80
+
81
+ return nextValue;
67
82
  },
68
83
  }),
69
84
  );
@@ -0,0 +1,41 @@
1
+ import { expectType } from 'ts-data-forge';
2
+ import type { SourceObservable } from '../types/index.mjs';
3
+ import { source } from './source.mjs';
4
+
5
+ describe(source, () => {
6
+ test('`next` returns the value it passed on', () => {
7
+ const count$ = source<number>(0);
8
+
9
+ assert.strictEqual(count$.next(1), 1);
10
+
11
+ assert.strictEqual(count$.getSnapshot().value, 1);
12
+ });
13
+
14
+ test('once completed, `next` passes nothing on and returns the value held', () => {
15
+ const count$ = source<number>(0);
16
+
17
+ const mut_seen: number[] = [];
18
+
19
+ count$.subscribe((value) => {
20
+ mut_seen.push(value);
21
+ });
22
+
23
+ count$.next(1);
24
+
25
+ count$.complete();
26
+
27
+ assert.strictEqual(count$.next(2), 1);
28
+
29
+ assert.deepStrictEqual(mut_seen, [0, 1]);
30
+ });
31
+
32
+ test('`next` returns the value only on a source with an initial value', () => {
33
+ const passed = source<number>(0).next(1);
34
+
35
+ expectType<typeof passed, number>('=');
36
+
37
+ assert.strictEqual(passed, 1);
38
+
39
+ expectType<SourceObservable<number>['next'], (value: number) => void>('=');
40
+ });
41
+ });
@@ -98,9 +98,6 @@ import type {
98
98
  * 'C3',
99
99
  * ]);
100
100
  * ```
101
- *
102
- * @note To improve code readability, consider using `createState` instead of `mergeMap`,
103
- * subscribing to `parentObservable` and calling `setState` within it.
104
101
  */
105
102
  export const mergeMap =
106
103
  <A, B>(
@@ -4,6 +4,7 @@ import type {
4
4
  DropInitialValueOperator,
5
5
  Observable,
6
6
  SkipUntilOperatorObservable,
7
+ Subscription,
7
8
  } from '../types/index.mjs';
8
9
 
9
10
  /**
@@ -65,12 +66,28 @@ const createSkipUntilObservable = <A,>(
65
66
  parentObservable: Observable<A>,
66
67
  notifier: Observable<unknown>,
67
68
  ): SkipUntilOperatorObservable<A> => {
68
- let mut_isSkipping = true;
69
+ // A notifier that already holds a value, or has completed, would call
70
+ // `stopSkipping` inside `subscribe`, before the subscription is stored.
71
+ let mut_isSkipping =
72
+ Optional.isNone(notifier.getSnapshot()) && !notifier.isCompleted;
73
+
74
+ let mut_subscription: Subscription | undefined = undefined;
75
+
76
+ const stopSkipping = (): void => {
77
+ mut_isSkipping = false;
78
+
79
+ mut_subscription?.unsubscribe();
80
+
81
+ mut_subscription = undefined;
82
+ };
69
83
 
70
84
  const observable = createSyncChildObservable<A, readonly [A]>(
71
85
  {
72
86
  parents: [parentObservable],
73
87
  initialValue: Optional.none,
88
+ onComplete: () => {
89
+ mut_subscription?.unsubscribe();
90
+ },
74
91
  },
75
92
  ({ setNext }) =>
76
93
  (updateToken) => {
@@ -88,14 +105,9 @@ const createSkipUntilObservable = <A,>(
88
105
  },
89
106
  );
90
107
 
91
- notifier.subscribe(
92
- () => {
93
- mut_isSkipping = false;
94
- },
95
- () => {
96
- mut_isSkipping = false;
97
- },
98
- );
108
+ if (mut_isSkipping) {
109
+ mut_subscription = notifier.subscribe(stopSkipping, stopSkipping);
110
+ }
99
111
 
100
112
  return observable;
101
113
  };
@@ -96,9 +96,6 @@ import type {
96
96
  * 'C3',
97
97
  * ]);
98
98
  * ```
99
- *
100
- * @note To improve code readability, consider using `createState` instead of `switchMap`,
101
- * subscribe to `parentObservable` and call `setState` within it.
102
99
  */
103
100
  export const switchMap =
104
101
  <A, B>(
@@ -0,0 +1,127 @@
1
+ import { source } from '../create/index.mjs';
2
+ import { take } from '../predefined/index.mjs';
3
+ import { skipUntil } from './skip-until.mjs';
4
+ import { takeUntil } from './take-until.mjs';
5
+
6
+ describe('takeUntil releases the notifier', () => {
7
+ test('when the result is completed before the notifier emits', () => {
8
+ const notifier$ = source<undefined>();
9
+
10
+ const result$ = source<number>().pipe(takeUntil(notifier$));
11
+
12
+ assert.isTrue(notifier$.hasSubscriber);
13
+
14
+ result$.complete();
15
+
16
+ assert.isFalse(notifier$.hasSubscriber);
17
+ });
18
+
19
+ test('when the parent completes', () => {
20
+ const notifier$ = source<undefined>();
21
+
22
+ const num$ = source<number>();
23
+
24
+ num$.pipe(takeUntil(notifier$));
25
+
26
+ num$.complete();
27
+
28
+ assert.isFalse(notifier$.hasSubscriber);
29
+ });
30
+
31
+ test('when the downstream completes', () => {
32
+ const notifier$ = source<undefined>();
33
+
34
+ const num$ = source<number>();
35
+
36
+ const taken$ = num$.pipe(takeUntil(notifier$)).pipe(take(1));
37
+
38
+ taken$.subscribe(() => {});
39
+
40
+ // `take(n)` completes on the emission after its `n`th.
41
+ num$.next(1);
42
+
43
+ num$.next(2);
44
+
45
+ assert.isTrue(taken$.isCompleted);
46
+
47
+ assert.isFalse(notifier$.hasSubscriber);
48
+ });
49
+
50
+ test('when the notifier emits', () => {
51
+ const notifier$ = source<undefined>();
52
+
53
+ const result$ = source<number>().pipe(takeUntil(notifier$));
54
+
55
+ notifier$.next(undefined);
56
+
57
+ assert.isTrue(result$.isCompleted);
58
+
59
+ assert.isFalse(notifier$.hasSubscriber);
60
+ });
61
+
62
+ test('when the notifier has already emitted', () => {
63
+ const notifier$ = source<number>(0);
64
+
65
+ const result$ = source<number>().pipe(takeUntil(notifier$));
66
+
67
+ assert.isTrue(result$.isCompleted);
68
+
69
+ assert.isFalse(notifier$.hasSubscriber);
70
+ });
71
+ });
72
+
73
+ describe('skipUntil releases the notifier', () => {
74
+ test('when the notifier emits', () => {
75
+ const notifier$ = source<undefined>();
76
+
77
+ const num$ = source<number>();
78
+
79
+ const result$ = num$.pipe(skipUntil(notifier$));
80
+
81
+ const mut_history: number[] = [];
82
+
83
+ result$.subscribe((x) => {
84
+ mut_history.push(x);
85
+ });
86
+
87
+ num$.next(1);
88
+
89
+ notifier$.next(undefined);
90
+
91
+ assert.isFalse(notifier$.hasSubscriber);
92
+
93
+ num$.next(2);
94
+
95
+ assert.deepStrictEqual(mut_history, [2]);
96
+ });
97
+
98
+ test('when the notifier has already emitted', () => {
99
+ const notifier$ = source<number>(0);
100
+
101
+ const num$ = source<number>();
102
+
103
+ const result$ = num$.pipe(skipUntil(notifier$));
104
+
105
+ const mut_history: number[] = [];
106
+
107
+ result$.subscribe((x) => {
108
+ mut_history.push(x);
109
+ });
110
+
111
+ assert.isFalse(notifier$.hasSubscriber);
112
+
113
+ num$.next(1);
114
+
115
+ assert.deepStrictEqual(mut_history, [1]);
116
+ });
117
+
118
+ test('when the result is completed before the notifier emits', () => {
119
+ const notifier$ = source<undefined>();
120
+
121
+ const result$ = source<number>().pipe(skipUntil(notifier$));
122
+
123
+ result$.complete();
124
+
125
+ assert.isFalse(notifier$.hasSubscriber);
126
+ });
127
+ });
@@ -3,6 +3,7 @@ import { createSyncChildObservable } from '../base/index.mjs';
3
3
  import type {
4
4
  KeepInitialValueOperator,
5
5
  Observable,
6
+ Subscription,
6
7
  TakeUntilOperatorObservable,
7
8
  } from '../types/index.mjs';
8
9
 
@@ -68,10 +69,15 @@ const createTakeUntilObservable = <A,>(
68
69
  parentObservable: Observable<A>,
69
70
  notifier: Observable<unknown>,
70
71
  ): TakeUntilOperatorObservable<A> => {
72
+ let mut_subscription: Subscription | undefined = undefined;
73
+
71
74
  const observable = createSyncChildObservable<A, readonly [A]>(
72
75
  {
73
76
  parents: [parentObservable],
74
77
  initialValue: parentObservable.getSnapshot(),
78
+ onComplete: () => {
79
+ mut_subscription?.unsubscribe();
80
+ },
75
81
  },
76
82
  ({ setNext }) =>
77
83
  (updateToken) => {
@@ -88,7 +94,7 @@ const createTakeUntilObservable = <A,>(
88
94
  },
89
95
  );
90
96
 
91
- notifier.subscribe(
97
+ const subscription = notifier.subscribe(
92
98
  () => {
93
99
  observable.complete();
94
100
  },
@@ -97,5 +103,13 @@ const createTakeUntilObservable = <A,>(
97
103
  },
98
104
  );
99
105
 
106
+ // A notifier that already holds a value completes `observable` inside
107
+ // `subscribe`, before `onComplete` can see the subscription.
108
+ if (observable.isCompleted) {
109
+ subscription.unsubscribe();
110
+ } else {
111
+ mut_subscription = subscription;
112
+ }
113
+
100
114
  return observable;
101
115
  };
@@ -3,6 +3,7 @@ import { createSyncChildObservable } from '../base/index.mjs';
3
3
  import type {
4
4
  KeepInitialValueOperator,
5
5
  Observable,
6
+ Subscription,
6
7
  WithBufferedFromOperatorObservable,
7
8
  } from '../types/index.mjs';
8
9
  import { maxDepth } from '../utils/index.mjs';
@@ -86,6 +87,8 @@ const createWithBufferedFromObservable = <A, B>(
86
87
  mut_bufferedValues = [];
87
88
  };
88
89
 
90
+ let mut_subscription: Subscription | undefined = undefined;
91
+
89
92
  const result = createSyncChildObservable<
90
93
  readonly [A, readonly B[]],
91
94
  readonly [A]
@@ -104,6 +107,11 @@ const createWithBufferedFromObservable = <A, B>(
104
107
  Optional.isNone(me) ? [] : [me.value],
105
108
  ] as const),
106
109
  ).value,
110
+ onComplete: () => {
111
+ mut_subscription?.unsubscribe();
112
+
113
+ clearBuffer();
114
+ },
107
115
  },
108
116
  ({ setNext }) =>
109
117
  (updateToken) => {
@@ -122,9 +130,13 @@ const createWithBufferedFromObservable = <A, B>(
122
130
  },
123
131
  );
124
132
 
125
- observable.subscribe((value) => {
126
- mut_bufferedValues = Arr.toPushed(mut_bufferedValues, value);
127
- });
133
+ // A child completed at creation has already run `onComplete`; subscribing
134
+ // after it would leave a subscription nothing releases.
135
+ if (!result.isCompleted) {
136
+ mut_subscription = observable.subscribe((value) => {
137
+ mut_bufferedValues = Arr.toPushed(mut_bufferedValues, value);
138
+ });
139
+ }
128
140
 
129
141
  return result;
130
142
  };
@@ -0,0 +1,56 @@
1
+ import { source } from '../create/index.mjs';
2
+ import { withBufferedFrom } from './with-buffered-from.mjs';
3
+
4
+ describe('withBufferedFrom teardown', () => {
5
+ test('unsubscribes from the buffered observable on complete', () => {
6
+ const data$ = source<string>();
7
+
8
+ const trigger$ = source<number>();
9
+
10
+ const result$ = trigger$.pipe(withBufferedFrom(data$));
11
+
12
+ assert.isTrue(data$.hasSubscriber);
13
+
14
+ result$.complete();
15
+
16
+ assert.isTrue(result$.isCompleted);
17
+
18
+ assert.isFalse(data$.hasSubscriber);
19
+ });
20
+
21
+ test('unsubscribes from the buffered observable when the parent completes', () => {
22
+ const data$ = source<string>();
23
+
24
+ const trigger$ = source<number>();
25
+
26
+ const result$ = trigger$.pipe(withBufferedFrom(data$));
27
+
28
+ trigger$.complete();
29
+
30
+ assert.isTrue(result$.isCompleted);
31
+
32
+ assert.isFalse(data$.hasSubscriber);
33
+ });
34
+
35
+ test('keeps buffering while subscribed', () => {
36
+ const data$ = source<string>();
37
+
38
+ const trigger$ = source<number>();
39
+
40
+ const result$ = trigger$.pipe(withBufferedFrom(data$));
41
+
42
+ const mut_history: (readonly [number, readonly string[]])[] = [];
43
+
44
+ result$.subscribe((v) => {
45
+ mut_history.push(v);
46
+ });
47
+
48
+ data$.next('A');
49
+
50
+ data$.next('B');
51
+
52
+ trigger$.next(1);
53
+
54
+ assert.deepStrictEqual(mut_history, [[1, ['A', 'B']]]);
55
+ });
56
+ });
@@ -20,7 +20,11 @@ import type { NonEmptyUnknownList } from './types.mjs';
20
20
  // RootObservable
21
21
 
22
22
  export type InitializedSourceObservable<A> = Readonly<{
23
- next: (value: A) => void;
23
+ /**
24
+ * Passes `value` on and returns it. Once completed, passes nothing on and
25
+ * returns the value held.
26
+ */
27
+ next: (value: A) => A;
24
28
  }> &
25
29
  InitializedRootObservable<A>;
26
30
 
@@ -35,9 +35,28 @@ type CreateObservableType<A, Kind extends ObservableKind> = Readonly<{
35
35
  hasChild: boolean;
36
36
  hasActiveChild: () => boolean;
37
37
 
38
+ /**
39
+ * Whether this observable completes itself once nothing uses it any more —
40
+ * once it has neither subscribers nor live children when one of its children
41
+ * completes. True when completing it would release something: it has a
42
+ * teardown of its own (a timer, an inner subscription) or a parent with
43
+ * `autoComplete`. A `source` or `createState` and everything derived from
44
+ * it alone are never completed this way, so completing a derived observable
45
+ * cannot end a state that something else may still read (#2133).
46
+ */
47
+ autoComplete: boolean;
48
+
38
49
  tryUpdate: (updateToken: UpdateToken) => void;
39
50
  tryComplete: () => void;
40
51
  complete: () => void;
52
+
53
+ /**
54
+ * Completes this observable and takes it out of the graph, without telling
55
+ * its parents: unlike `complete()`, it never completes an upstream
56
+ * observable, whatever its `autoComplete`. Its descendants are completed as
57
+ * by `complete()` once none of their parents is live.
58
+ */
59
+ dispose: () => void;
41
60
  subscribe: (onNext: (v: A) => void, onComplete?: () => void) => Subscription;
42
61
 
43
62
  pipe: PipeMethod<A>;
@@ -18,13 +18,13 @@ import { source, type Observable } from '../core/index.mjs';
18
18
  *
19
19
  * emitClick(); // logs: Clicked!
20
20
  *
21
- * assert.deepStrictEqual(mut_clickCount.value, 1);
21
+ * assert.strictEqual(mut_clickCount.value, 1);
22
22
  *
23
23
  * emitClick();
24
24
  *
25
25
  * emitClick();
26
26
  *
27
- * assert.deepStrictEqual(mut_clickCount.value, 3);
27
+ * assert.strictEqual(mut_clickCount.value, 3);
28
28
  * ```
29
29
  */
30
30
  export const createEventEmitter = (): readonly [
@@ -11,7 +11,9 @@ import { source, type InitializedObservable } from '../core/index.mjs';
11
11
  * @param initialState - The initial value of the state
12
12
  * @param options - {@link StateOptions}: `equals`, to pass on only the
13
13
  * dispatches that change the state
14
- * @returns A 3-element tuple: `[state, dispatch, { getSnapshot, initialState }]`
14
+ * @returns A 3-element tuple: `[state, dispatch, { getSnapshot, initialState }]`.
15
+ * `dispatch` returns the state it leaves; once `state` is completed, that
16
+ * is the state held.
15
17
  *
16
18
  * @example
17
19
  * ```ts
@@ -67,6 +69,13 @@ import { source, type InitializedObservable } from '../core/index.mjs';
67
69
  *
68
70
  * assert.deepStrictEqual(countHistory, [0, 2]);
69
71
  * ```
72
+ *
73
+ * @note Do not call `dispatch` from a subscriber or a projection while another
74
+ * update is propagating: the write propagates immediately, so subscribers can
75
+ * see an intermediate state, and it logs a `console.warn` (once per
76
+ * observable). Derive the value from its source instead —
77
+ * `const b$ = a$.pipe(map((a) => a * 2))` rather than
78
+ * `a$.subscribe((a) => { setB(a * 2); })`.
70
79
  */
71
80
  export const createReducer = <S, A>(
72
81
  reducer: Reducer<S, A>,
@@ -89,13 +98,10 @@ export const createReducer = <S, A>(
89
98
 
90
99
  const nextState = reducer(currentState, action);
91
100
 
92
- if (equals?.(currentState, nextState) === true) {
93
- return currentState;
94
- }
95
-
96
- state.next(nextState);
97
-
98
- return nextState;
101
+ // Once completed, `next` keeps the state held and returns it.
102
+ return equals?.(currentState, nextState) === true
103
+ ? currentState
104
+ : state.next(nextState);
99
105
  };
100
106
 
101
107
  const getSnapshot = (): S => state.getSnapshot().value;
@@ -65,6 +65,18 @@ describe(createReducer, () => {
65
65
 
66
66
  assert.deepStrictEqual(mut_asked, [[0, 5]]);
67
67
  });
68
+
69
+ test('once the state is completed, a dispatch returns the current state', () => {
70
+ const [state, dispatch, { getSnapshot }] = createReducer(keepOrAdd, 0);
71
+
72
+ dispatch({ type: 'add', amount: 1 });
73
+
74
+ state.complete();
75
+
76
+ assert.strictEqual(dispatch({ type: 'add', amount: 2 }), 1);
77
+
78
+ assert.strictEqual(getSnapshot(), 1);
79
+ });
68
80
  });
69
81
 
70
82
  type Count = Readonly<{ count: number }>;
@@ -88,6 +88,13 @@ const reducer = <S,>(state: S, action: Action<S>): S => {
88
88
  * 'label:bug page:2',
89
89
  * ]);
90
90
  * ```
91
+ *
92
+ * @note Do not call `setState`, `updateState` or `resetState` from a subscriber or a projection while another
93
+ * update is propagating: the write propagates immediately, so subscribers can
94
+ * see an intermediate state, and it logs a `console.warn` (once per
95
+ * observable). Derive the value from its source instead —
96
+ * `const b$ = a$.pipe(map((a) => a * 2))` rather than
97
+ * `a$.subscribe((a) => { setB(a * 2); })`.
91
98
  */
92
99
  export const createState = <S,>(
93
100
  initialState: S,
@@ -34,6 +34,26 @@ describe(createState, () => {
34
34
  assert.deepStrictEqual(seen, [0, 1]);
35
35
  });
36
36
 
37
+ test('once the state is completed, setState, updateState and resetState return the current value', () => {
38
+ const [state, setState, { updateState, resetState, getSnapshot }] =
39
+ createState(0);
40
+
41
+ setState(1);
42
+
43
+ state.complete();
44
+
45
+ assert.strictEqual(setState(2), 1);
46
+
47
+ assert.strictEqual(
48
+ updateState((current) => current + 1),
49
+ 1,
50
+ );
51
+
52
+ assert.strictEqual(resetState(), 1);
53
+
54
+ assert.strictEqual(getSnapshot(), 1);
55
+ });
56
+
37
57
  // What `equals` is for: whatever reads the state, directly or through
38
58
  // anything derived from it, is told only of a real change, and an equal
39
59
  // value written over the current one leaves the object that is there.