synstate 5.0.0 → 6.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 (39) hide show
  1. package/README.md +3 -3
  2. package/dist/core/class/child-observable-class.d.mts +26 -7
  3. package/dist/core/class/child-observable-class.d.mts.map +1 -1
  4. package/dist/core/class/child-observable-class.mjs +31 -0
  5. package/dist/core/class/child-observable-class.mjs.map +1 -1
  6. package/dist/core/operators/keep-equal-parts.d.mts +1 -3
  7. package/dist/core/operators/keep-equal-parts.d.mts.map +1 -1
  8. package/dist/core/operators/keep-equal-parts.mjs +0 -2
  9. package/dist/core/operators/keep-equal-parts.mjs.map +1 -1
  10. package/dist/core/operators/map.d.mts +30 -18
  11. package/dist/core/operators/map.d.mts.map +1 -1
  12. package/dist/core/operators/map.mjs +24 -10
  13. package/dist/core/operators/map.mjs.map +1 -1
  14. package/dist/core/operators/merge-map.mjs +1 -3
  15. package/dist/core/operators/merge-map.mjs.map +1 -1
  16. package/dist/core/operators/switch-map.mjs +1 -3
  17. package/dist/core/operators/switch-map.mjs.map +1 -1
  18. package/dist/core/predefined/operators/attach-index.d.mts +4 -2
  19. package/dist/core/predefined/operators/attach-index.d.mts.map +1 -1
  20. package/dist/core/predefined/operators/attach-index.mjs +36 -2
  21. package/dist/core/predefined/operators/attach-index.mjs.map +1 -1
  22. package/dist/core/predefined/operators/map-to.d.mts +6 -2
  23. package/dist/core/predefined/operators/map-to.d.mts.map +1 -1
  24. package/dist/core/predefined/operators/map-to.mjs +5 -1
  25. package/dist/core/predefined/operators/map-to.mjs.map +1 -1
  26. package/dist/utils/create-reducer.d.mts +1 -1
  27. package/dist/utils/create-state.d.mts +1 -1
  28. package/package.json +13 -13
  29. package/src/core/class/child-observable-class.mts +45 -6
  30. package/src/core/operators/flat-map-sync-inner.test.mts +90 -0
  31. package/src/core/operators/keep-equal-parts.mts +1 -3
  32. package/src/core/operators/map-cut-off.test.mts +215 -0
  33. package/src/core/operators/map.mts +48 -31
  34. package/src/core/operators/merge-map.mts +1 -3
  35. package/src/core/operators/switch-map.mts +1 -3
  36. package/src/core/predefined/operators/attach-index.mts +62 -5
  37. package/src/core/predefined/operators/map-to.mts +7 -3
  38. package/src/utils/create-reducer.mts +1 -1
  39. package/src/utils/create-state.mts +1 -1
@@ -14,7 +14,9 @@ import type {
14
14
  NonEmptyUnknownList,
15
15
  Observable,
16
16
  Operator,
17
+ Subscription,
17
18
  SyncChildObservable,
19
+ UpdateToken,
18
20
  WithInitialValueOperator,
19
21
  Wrap,
20
22
  } from '../types/index.mjs';
@@ -196,6 +198,40 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
196
198
  this.complete();
197
199
  });
198
200
  }
201
+
202
+ /**
203
+ * Subscribes to the inner observable that the projection of `switchMap` /
204
+ * `mergeMap` returned.
205
+ *
206
+ * If the inner observable already holds a value (`just(x)`, a state), it
207
+ * emits that value synchronously while being subscribed. That first value
208
+ * is emitted with `updateToken`, the token of the update that ran the
209
+ * projection, so it joins the propagation in progress like a sync
210
+ * operator's value would. Starting an update of its own instead would
211
+ * reach the nodes below before the current update reached this
212
+ * observable's siblings: a node reading both would see the new inner value
213
+ * beside stale sibling values (a glitch), and would emit twice.
214
+ *
215
+ * Values the inner observable emits later start updates of their own.
216
+ */
217
+ protected subscribeInner(
218
+ inner: Observable<A>,
219
+ updateToken: UpdateToken,
220
+ ): Subscription {
221
+ let mut_isSubscribing = true;
222
+
223
+ const subscription = inner.subscribe((value) => {
224
+ if (mut_isSubscribing) {
225
+ this.setNext(value, updateToken);
226
+ } else {
227
+ this.startUpdate(value);
228
+ }
229
+ });
230
+
231
+ mut_isSubscribing = false;
232
+
233
+ return subscription;
234
+ }
199
235
  }
200
236
 
201
237
  /**
@@ -273,12 +309,15 @@ const isDevelopmentProcess = (): boolean => {
273
309
  };
274
310
 
275
311
  /**
276
- * Whether {@link registerChild} checks for a circular dependency: everywhere
277
- * but in a process whose `NODE_ENV` is `production`. A child's parents exist
278
- * before it and cannot be changed afterwards, so only code that rewrites an
279
- * observable's readonly `parents` can make a cycle, while the walk over every
280
- * ancestor is paid on every creation. Where there is no `process` (a browser
281
- * bundle) the check is off.
312
+ * Whether {@link registerChild} checks for a circular dependency: on in
313
+ * development, off in production. The public API cannot make a cycle — a
314
+ * child's parents exist before it and cannot be changed afterwards — so the
315
+ * check is a safety net for what propagation assumes (a DAG, a depth for every
316
+ * node) against code that rewrites an observable's readonly `parents`, and
317
+ * development is where that code runs first. It walks every ancestor of each
318
+ * new observable, a cost production does not pay: the check is off when
319
+ * `NODE_ENV` is `production`, and where there is no `process` (a browser
320
+ * bundle).
282
321
  */
283
322
  export const checksCircularDependency: boolean = isDevelopmentProcess();
284
323
 
@@ -0,0 +1,90 @@
1
+ import type { FixedLengthTuple } from 'ts-type-forge';
2
+ import { combine } from '../combine/index.mjs';
3
+ import { just, source } from '../create/index.mjs';
4
+ import { map } from './map.mjs';
5
+ import { mergeMap } from './merge-map.mjs';
6
+ import { switchMap } from './switch-map.mjs';
7
+
8
+ describe('an inner observable that holds a value is passed on in the update that projected it', () => {
9
+ test('switchMap: a sibling read with it is never stale', () => {
10
+ const a$ = source<number>(1);
11
+
12
+ // Created before `m$`, so the update visits it first.
13
+ const s$ = a$.pipe(switchMap((x) => just(`s${x.toString()}`)));
14
+
15
+ const m$ = a$.pipe(map((x) => `m${x.toString()}`));
16
+
17
+ const mut_history: string[] = [];
18
+
19
+ combine([m$, s$]).subscribe(([m, s]) => {
20
+ mut_history.push(`${m},${s}`);
21
+ });
22
+
23
+ a$.next(2);
24
+
25
+ a$.next(3);
26
+
27
+ assert.deepStrictEqual(mut_history, ['m2,s2', 'm3,s3']);
28
+ });
29
+
30
+ test('switchMap: a child of both it and its parent emits once per update', () => {
31
+ const a$ = source<number>(1);
32
+
33
+ const s$ = a$.pipe(switchMap((x) => just(x * 10)));
34
+
35
+ const mut_history: FixedLengthTuple<2, number>[] = [];
36
+
37
+ combine([a$, s$]).subscribe((pair) => {
38
+ mut_history.push(pair);
39
+ });
40
+
41
+ a$.next(2);
42
+
43
+ a$.next(3);
44
+
45
+ assert.deepStrictEqual(mut_history, [
46
+ [2, 20],
47
+ [3, 30],
48
+ ]);
49
+ });
50
+
51
+ test('switchMap: an inner that emits later still starts an update of its own', () => {
52
+ const a$ = source<number>();
53
+
54
+ const inner$ = source<number>();
55
+
56
+ const s$ = a$.pipe(switchMap(() => inner$));
57
+
58
+ const mut_history: number[] = [];
59
+
60
+ s$.subscribe((v) => {
61
+ mut_history.push(v);
62
+ });
63
+
64
+ a$.next(1);
65
+
66
+ inner$.next(5);
67
+
68
+ inner$.next(6);
69
+
70
+ assert.deepStrictEqual(mut_history, [5, 6]);
71
+ });
72
+
73
+ test('mergeMap: a sibling read with it is never stale', () => {
74
+ const a$ = source<number>(1);
75
+
76
+ const s$ = a$.pipe(mergeMap((x) => just(`s${x.toString()}`)));
77
+
78
+ const m$ = a$.pipe(map((x) => `m${x.toString()}`));
79
+
80
+ const mut_history: string[] = [];
81
+
82
+ combine([m$, s$]).subscribe(([m, s]) => {
83
+ mut_history.push(`${m},${s}`);
84
+ });
85
+
86
+ a$.next(2);
87
+
88
+ assert.deepStrictEqual(mut_history, ['m2,s2']);
89
+ });
90
+ });
@@ -59,9 +59,7 @@ import type {
59
59
  *
60
60
  * const board$ = message$.pipe(keepEqualParts());
61
61
  *
62
- * const firstCard$ = board$
63
- * .pipe(map((board) => board.cards[0]))
64
- * .pipe(skipIfNoChange());
62
+ * const firstCard$ = board$.pipe(map((board) => board.cards[0]));
65
63
  *
66
64
  * const boardHistory: Board[] = [];
67
65
  *
@@ -0,0 +1,215 @@
1
+ import { expectType } from 'ts-data-forge';
2
+ import { combine } from '../combine/index.mjs';
3
+ import { source } from '../create/index.mjs';
4
+ import { mapTo, withIndex } from '../predefined/index.mjs';
5
+ import type { Observable } from '../types/index.mjs';
6
+ import { map } from './map.mjs';
7
+
8
+ describe('map stops a result equal to the one it holds', () => {
9
+ test('a result equal by Object.is is not emitted', () => {
10
+ const a$ = source<number>();
11
+
12
+ const parity$ = a$.pipe(map((x) => x % 2));
13
+
14
+ const mut_history: number[] = [];
15
+
16
+ parity$.subscribe((p) => {
17
+ mut_history.push(p);
18
+ });
19
+
20
+ a$.next(1);
21
+
22
+ a$.next(3);
23
+
24
+ a$.next(4);
25
+
26
+ a$.next(6);
27
+
28
+ a$.next(7);
29
+
30
+ assert.deepStrictEqual(mut_history, [1, 0, 1]);
31
+ });
32
+
33
+ test('the value computed from an initial value counts as the one held', () => {
34
+ const a$ = source<number>(1);
35
+
36
+ const parity$ = a$.pipe(map((x) => x % 2));
37
+
38
+ const mut_history: number[] = [];
39
+
40
+ parity$.subscribe((p) => {
41
+ mut_history.push(p);
42
+ });
43
+
44
+ a$.next(3);
45
+
46
+ a$.next(4);
47
+
48
+ assert.deepStrictEqual(mut_history, [1, 0]);
49
+ });
50
+
51
+ test('a node below is not woken when the result is equal', () => {
52
+ const a$ = source<number>(1);
53
+
54
+ const parity$ = a$.pipe(map((x) => x % 2));
55
+
56
+ let mut_calls = 0;
57
+
58
+ const label$ = parity$.pipe(
59
+ map((p) => {
60
+ mut_calls += 1;
61
+
62
+ return p === 0 ? 'even' : 'odd';
63
+ }),
64
+ );
65
+
66
+ label$.subscribe(() => {});
67
+
68
+ const callsAfterSetup = mut_calls;
69
+
70
+ a$.next(3);
71
+
72
+ a$.next(5);
73
+
74
+ assert.strictEqual(mut_calls, callsAfterSetup);
75
+
76
+ a$.next(6);
77
+
78
+ assert.strictEqual(mut_calls, callsAfterSetup + 1);
79
+ });
80
+
81
+ test('a node reading a stopped map and another parent still updates once', () => {
82
+ const a$ = source<number>(1);
83
+
84
+ const parity$ = a$.pipe(map((x) => x % 2));
85
+
86
+ const mut_history: string[] = [];
87
+
88
+ combine([a$, parity$]).subscribe(([a, p]) => {
89
+ mut_history.push(`${a.toString()},${p.toString()}`);
90
+ });
91
+
92
+ a$.next(3);
93
+
94
+ a$.next(4);
95
+
96
+ assert.deepStrictEqual(mut_history, ['1,1', '3,1', '4,0']);
97
+ });
98
+
99
+ test('NaN equals NaN, and 0 differs from -0, as Object.is says', () => {
100
+ const a$ = source<number>();
101
+
102
+ const r$ = a$.pipe(map((x) => x));
103
+
104
+ const mut_history: number[] = [];
105
+
106
+ r$.subscribe((v) => {
107
+ mut_history.push(v);
108
+ });
109
+
110
+ a$.next(Number.NaN);
111
+
112
+ a$.next(Number.NaN);
113
+
114
+ a$.next(0);
115
+
116
+ a$.next(-0);
117
+
118
+ assert.deepStrictEqual(mut_history, [Number.NaN, 0, -0]);
119
+ });
120
+
121
+ test('`equals` replaces Object.is', () => {
122
+ const a$ = source<Readonly<{ id: number; name: string }>>();
123
+
124
+ const id$ = a$.pipe(
125
+ map((x) => ({ id: x.id }), { equals: (p, q) => p.id === q.id }),
126
+ );
127
+
128
+ const mut_history: number[] = [];
129
+
130
+ id$.subscribe(({ id }) => {
131
+ mut_history.push(id);
132
+ });
133
+
134
+ a$.next({ id: 1, name: 'a' });
135
+
136
+ a$.next({ id: 1, name: 'b' });
137
+
138
+ a$.next({ id: 2, name: 'b' });
139
+
140
+ assert.deepStrictEqual(mut_history, [1, 2]);
141
+ });
142
+
143
+ test('`equals: () => false` emits every result', () => {
144
+ const click$ = source<undefined>();
145
+
146
+ const one$ = click$.pipe(map(() => 1, { equals: () => false }));
147
+
148
+ const mut_history: number[] = [];
149
+
150
+ one$.subscribe((v) => {
151
+ mut_history.push(v);
152
+ });
153
+
154
+ click$.next(undefined);
155
+
156
+ click$.next(undefined);
157
+
158
+ assert.deepStrictEqual(mut_history, [1, 1]);
159
+ });
160
+
161
+ test('the mapping function no longer receives an index', () => {
162
+ const a$ = source<number>();
163
+
164
+ // @ts-expect-error `map` passes the value alone; use `withIndex`.
165
+ const r$ = a$.pipe(map((x: number, i: number) => x + i));
166
+
167
+ expect(r$).toBeDefined();
168
+ });
169
+ });
170
+
171
+ describe('operators that turn events into values emit every event', () => {
172
+ test('mapTo emits its value on every event', () => {
173
+ const reset$ = source<undefined>();
174
+
175
+ const action$ = reset$.pipe(mapTo('reset'));
176
+
177
+ expectType<typeof action$, Observable<'reset'>>('=');
178
+
179
+ const mut_history: string[] = [];
180
+
181
+ action$.subscribe((v) => {
182
+ mut_history.push(v);
183
+ });
184
+
185
+ reset$.next(undefined);
186
+
187
+ reset$.next(undefined);
188
+
189
+ assert.deepStrictEqual(mut_history, ['reset', 'reset']);
190
+ });
191
+
192
+ test('withIndex counts from 0, and gives -1 to an initial value', () => {
193
+ const a$ = source<string>('init');
194
+
195
+ const indexed$ = a$.pipe(withIndex());
196
+
197
+ assert.deepStrictEqual(indexed$.getSnapshot().value, [-1, 'init']);
198
+
199
+ const mut_history: (readonly [number, string])[] = [];
200
+
201
+ indexed$.subscribe((v) => {
202
+ mut_history.push(v);
203
+ });
204
+
205
+ a$.next('x');
206
+
207
+ a$.next('x');
208
+
209
+ assert.deepStrictEqual(mut_history, [
210
+ [-1, 'init'],
211
+ [0, 'x'],
212
+ [1, 'x'],
213
+ ]);
214
+ });
215
+ });
@@ -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 { Optional, SafeUint, asSafeUint, expectType } from 'ts-data-forge';
8
+ import { Optional, expectType } from 'ts-data-forge';
9
9
  import { SyncChildObservableClass } from '../class/index.mjs';
10
10
  import { source } from '../create/index.mjs';
11
11
  import type {
@@ -18,51 +18,64 @@ import type {
18
18
  import { withInitialValue } from './with-initial-value.mjs';
19
19
 
20
20
  /**
21
- * Transforms each value emitted by the source using a mapping function that also receives the emission index.
21
+ * Transforms each value emitted by the source with a mapping function, and
22
+ * emits the result unless it equals the one already held.
23
+ *
24
+ * The comparison is `Object.is` unless `equals` says otherwise, so a result
25
+ * that did not change wakes nothing below. To pass on every result — an event
26
+ * turned into a value, say — give `equals: () => false`, or use `mapTo`. The
27
+ * mapping function receives the value alone; `withIndex` attaches an index.
22
28
  *
23
29
  * @template A - The type of values from the source
24
30
  * @template B - The type of mapped values
25
- * @param mapFn - A function that maps each value (receives value and index)
26
- * @returns An operator that maps values with index
31
+ * @param mapFn - A function that maps each value
32
+ * @param options - `equals` compares a result with the one held
33
+ * @returns An operator that maps values
27
34
  *
28
35
  * @example
29
36
  * ```ts
30
37
  * // Timeline:
31
38
  * //
32
- * // num$ "A" "B" "C"
33
- * // indexed$ "0: A" "1: B" "2: C"
39
+ * // num$ 1 3 4 6 7
40
+ * // parity$ 1 0 1
34
41
  * //
35
42
  * // Explanation:
36
- * // - mapWithIndex transforms each value along with its index
37
- * // - Index starts at 0 and increments with each emission
43
+ * // - map transforms each value
44
+ * // - A result equal to the one held is not emitted
38
45
  *
39
- * const num$ = source<string>();
46
+ * const num$ = source<number>();
40
47
  *
41
- * const indexed$ = num$.pipe(map((x, i) => `${i}: ${x}`));
48
+ * const parity$ = num$.pipe(map((x) => x % 2));
42
49
  *
43
- * const valueHistory: string[] = [];
50
+ * const valueHistory: number[] = [];
44
51
  *
45
- * indexed$.subscribe((s) => {
46
- * valueHistory.push(s);
52
+ * parity$.subscribe((p) => {
53
+ * valueHistory.push(p);
47
54
  * });
48
55
  *
49
- * num$.next('A'); // 0: A
56
+ * num$.next(1); // 1
57
+ *
58
+ * num$.next(3); // still 1: not emitted
50
59
  *
51
- * num$.next('B'); // 1: B
60
+ * num$.next(4); // 0
52
61
  *
53
- * num$.next('C'); // 2: C
62
+ * num$.next(6); // still 0: not emitted
54
63
  *
55
- * assert.deepStrictEqual(valueHistory, ['0: A', '1: B', '2: C']);
64
+ * num$.next(7); // 1
65
+ *
66
+ * assert.deepStrictEqual(valueHistory, [1, 0, 1]);
56
67
  * ```
57
68
  */
58
69
  export const map = <A, B>(
59
- mapFn: (x: A, index: SafeUint | -1) => B,
70
+ mapFn: (x: A) => B,
71
+ options?: Readonly<{ equals?: (x: B, y: B) => boolean }>,
60
72
  ): KeepInitialValueOperator<A, B> =>
61
73
  // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
62
74
  ((parentObservable) =>
63
75
  new MapObservableClass(
64
76
  parentObservable,
65
77
  mapFn,
78
+ options?.equals ?? Object.is,
66
79
  )) as KeepInitialValueOperator<A, B>;
67
80
 
68
81
  /* implementation */
@@ -71,24 +84,23 @@ class MapObservableClass<A, B>
71
84
  extends SyncChildObservableClass<B, readonly [A]>
72
85
  implements MapOperatorObservable<A, B>
73
86
  {
74
- readonly #mapFn: (x: A, index: SafeUint | -1) => B;
75
- #mut_index: SafeUint | -1;
87
+ readonly #mapFn: (x: A) => B;
88
+ readonly #equals: (x: B, y: B) => boolean;
76
89
 
77
90
  constructor(
78
91
  parentObservable: Observable<A>,
79
- mapFn: (x: A, index: SafeUint | -1) => B,
92
+ mapFn: (x: A) => B,
93
+ equals: (x: B, y: B) => boolean,
80
94
  ) {
81
95
  super({
82
96
  parents: [parentObservable],
83
- initialValue: Optional.map(parentObservable.getSnapshot(), (x) =>
84
- mapFn(x, -1),
85
- ),
97
+ initialValue: Optional.map(parentObservable.getSnapshot(), mapFn),
86
98
  });
87
99
 
88
- this.#mut_index = -1;
89
-
90
100
  this.#mapFn = mapFn;
91
101
 
102
+ this.#equals = equals;
103
+
92
104
  this.attach();
93
105
  }
94
106
 
@@ -101,10 +113,15 @@ class MapObservableClass<A, B>
101
113
  return; // skip update
102
114
  }
103
115
 
104
- this.#mut_index =
105
- this.#mut_index === -1 ? asSafeUint(0) : SafeUint.add(1, this.#mut_index);
116
+ const next = this.#mapFn(sn.value);
117
+
118
+ const held = this.getSnapshot();
119
+
120
+ if (Optional.isSome(held) && this.#equals(held.value, next)) {
121
+ return; // the result did not change
122
+ }
106
123
 
107
- this.setNext(this.#mapFn(sn.value, this.#mut_index), updateToken);
124
+ this.setNext(next, updateToken);
108
125
  }
109
126
  }
110
127
 
@@ -116,7 +133,7 @@ if (import.meta.vitest !== undefined) {
116
133
  {
117
134
  const s: Observable<number> = source<number>();
118
135
 
119
- const _d1 = s.pipe(map((x, i) => x + i));
136
+ const _d1 = s.pipe(map((x) => x + 1));
120
137
 
121
138
  expectType<typeof _d1, Observable<number>>('=');
122
139
  }
@@ -126,7 +143,7 @@ if (import.meta.vitest !== undefined) {
126
143
 
127
144
  const m: InitializedObservable<number> = s.pipe(withInitialValue(0));
128
145
 
129
- const _d = m.pipe(map((x, i) => x + i));
146
+ const _d = m.pipe(map((x) => x + 1));
130
147
 
131
148
  expectType<typeof _d, InitializedObservable<number>>('=');
132
149
  }
@@ -174,9 +174,7 @@ class MergeMapObservableClass<A, B>
174
174
 
175
175
  const observable = innerScope.run(() => this.#mapToObservable(sn.value));
176
176
 
177
- const subscription = observable.subscribe((curr) => {
178
- this.startUpdate(curr);
179
- });
177
+ const subscription = this.subscribeInner(observable, updateToken);
180
178
 
181
179
  this.#mut_subscriptions = Arr.toPushed(
182
180
  this.#mut_subscriptions,
@@ -179,9 +179,7 @@ class SwitchMapObservableClass<A, B>
179
179
 
180
180
  const observable = innerScope.run(() => this.#mapToObservable(sn.value));
181
181
 
182
- this.#mut_subscription = observable.subscribe((curr) => {
183
- this.startUpdate(curr);
184
- });
182
+ this.#mut_subscription = this.subscribeInner(observable, updateToken);
185
183
  }
186
184
 
187
185
  protected override teardown(): void {
@@ -1,10 +1,23 @@
1
- import type { SafeUint } from 'ts-type-forge';
2
- import { map } from '../../operators/index.mjs';
3
- import type { KeepInitialValueOperator } from '../../types/index.mjs';
1
+ // Sumi bans classes, `this`, method syntax and writes to `this` outside a
2
+ // `mut_` binding; an observable is a class (`ObservableBaseClass` says why),
3
+ // so those rules are waived here (D-63).
4
+ // @sumi-expect-error-file classes/no-class
5
+ // @sumi-expect-error-file banned-syntax/no-this
6
+ // @sumi-expect-error-file functions/prefer-arrow-function
7
+ // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
+ import { Optional, SafeUint, asSafeUint } from 'ts-data-forge';
9
+ import { SyncChildObservableClass } from '../../class/index.mjs';
10
+ import type {
11
+ KeepInitialValueOperator,
12
+ Observable,
13
+ UpdateToken,
14
+ } from '../../types/index.mjs';
4
15
 
5
16
  /**
6
17
  * Attaches a sequential index to each emitted value, producing `[index, value]` tuples.
7
- * Index starts at 0 and increments with each emission.
18
+ * Index starts at 0 and increments with each emission; a value the source
19
+ * already holds when this is applied gets -1. Use it where `map` used to
20
+ * receive an index.
8
21
  *
9
22
  * @template A - The type of values from the source
10
23
  * @returns An operator that emits `[index, value]` tuples
@@ -54,10 +67,54 @@ import type { KeepInitialValueOperator } from '../../types/index.mjs';
54
67
  export const withIndex = <A,>(): KeepInitialValueOperator<
55
68
  A,
56
69
  readonly [SafeUint | -1, A]
57
- > => map((a, i) => [i, a] as const);
70
+ > =>
71
+ // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
72
+ ((parentObservable) =>
73
+ new WithIndexObservableClass(parentObservable)) as KeepInitialValueOperator<
74
+ A,
75
+ readonly [SafeUint | -1, A]
76
+ >;
58
77
 
59
78
  /**
60
79
  * Alias for `withIndex`.
61
80
  * @see withIndex
62
81
  */
63
82
  export const attachIndex = withIndex;
83
+
84
+ /* implementation */
85
+
86
+ class WithIndexObservableClass<A> extends SyncChildObservableClass<
87
+ readonly [SafeUint | -1, A],
88
+ readonly [A]
89
+ > {
90
+ #mut_index: SafeUint | -1;
91
+
92
+ constructor(parentObservable: Observable<A>) {
93
+ super({
94
+ parents: [parentObservable],
95
+ initialValue: Optional.map(
96
+ parentObservable.getSnapshot(),
97
+ (a) => [-1, a] as const,
98
+ ),
99
+ });
100
+
101
+ this.#mut_index = -1;
102
+
103
+ this.attach();
104
+ }
105
+
106
+ override tryUpdate(updateToken: UpdateToken): void {
107
+ const par = this.parents[0];
108
+
109
+ const sn = par.getSnapshot();
110
+
111
+ if (par.updateToken !== updateToken || Optional.isNone(sn)) {
112
+ return; // skip update
113
+ }
114
+
115
+ this.#mut_index =
116
+ this.#mut_index === -1 ? asSafeUint(0) : SafeUint.add(1, this.#mut_index);
117
+
118
+ this.setNext([this.#mut_index, sn.value], updateToken);
119
+ }
120
+ }
@@ -3,7 +3,11 @@ import type { KeepInitialValueOperator } from '../../types/index.mjs';
3
3
 
4
4
  /**
5
5
  * Maps all emitted values to a constant value, ignoring the source values.
6
- * Equivalent to `map(() => value)`.
6
+ * Emits on every source value although the value never changes — where
7
+ * `map(() => value)` would stop every result after the first as equal — so
8
+ * it is the one for turning events into a value. The value's type is
9
+ * inferred as written, as with `as const`: `mapTo('reset')` gives
10
+ * `'reset'`, not `string`.
7
11
  *
8
12
  * @template A - The type of values from the source
9
13
  * @template B - The type of the constant value
@@ -41,5 +45,5 @@ import type { KeepInitialValueOperator } from '../../types/index.mjs';
41
45
  * assert.deepStrictEqual(valueHistory, [1, 1, 1]);
42
46
  * ```
43
47
  */
44
- export const mapTo = <A, B>(value: B): KeepInitialValueOperator<A, B> =>
45
- map(() => value);
48
+ export const mapTo = <A, const B>(value: B): KeepInitialValueOperator<A, B> =>
49
+ map(() => value, { equals: () => false });
@@ -150,7 +150,7 @@ export type StateOptions<S> = Readonly<{
150
150
  * written, and every part of it deeply equal to the same part of the
151
151
  * current state is replaced by that part (`replaceEqualDeep` of
152
152
  * ts-data-forge): a reader of an unchanged part keeps the object it has, so
153
- * `skipIfNoChange()` with `Object.is` stops it. When the whole next state is
153
+ * `map`, comparing with `Object.is`, stops there. When the whole next state is
154
154
  * deeply equal, the current state is kept and nothing is passed on, so
155
155
  * `equals` can stay `Object.is`.
156
156
  *