synstate 4.0.0 → 5.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 (77) hide show
  1. package/README.md +58 -1
  2. package/dist/core/class/child-observable-class.d.mts +4 -7
  3. package/dist/core/class/child-observable-class.d.mts.map +1 -1
  4. package/dist/core/class/child-observable-class.mjs +8 -25
  5. package/dist/core/class/child-observable-class.mjs.map +1 -1
  6. package/dist/core/class/create-child-observable.d.mts +8 -5
  7. package/dist/core/class/create-child-observable.d.mts.map +1 -1
  8. package/dist/core/class/create-child-observable.mjs.map +1 -1
  9. package/dist/core/class/manager-observable-class.d.mts +4 -15
  10. package/dist/core/class/manager-observable-class.d.mts.map +1 -1
  11. package/dist/core/class/manager-observable-class.mjs +5 -91
  12. package/dist/core/class/manager-observable-class.mjs.map +1 -1
  13. package/dist/core/class/observable-base-class.d.mts +9 -2
  14. package/dist/core/class/observable-base-class.d.mts.map +1 -1
  15. package/dist/core/class/observable-base-class.mjs +17 -0
  16. package/dist/core/class/observable-base-class.mjs.map +1 -1
  17. package/dist/core/class/observable-node.d.mts +4 -8
  18. package/dist/core/class/observable-node.d.mts.map +1 -1
  19. package/dist/core/class/observable-node.mjs.map +1 -1
  20. package/dist/core/class/propagation-state.d.mts +26 -0
  21. package/dist/core/class/propagation-state.d.mts.map +1 -1
  22. package/dist/core/class/propagation-state.mjs +190 -0
  23. package/dist/core/class/propagation-state.mjs.map +1 -1
  24. package/dist/core/create/empty.d.mts +32 -0
  25. package/dist/core/create/empty.d.mts.map +1 -0
  26. package/dist/core/create/empty.mjs +39 -0
  27. package/dist/core/create/empty.mjs.map +1 -0
  28. package/dist/core/create/index.d.mts +1 -0
  29. package/dist/core/create/index.d.mts.map +1 -1
  30. package/dist/core/create/index.mjs +1 -0
  31. package/dist/core/create/index.mjs.map +1 -1
  32. package/dist/core/operators/index.d.mts +1 -0
  33. package/dist/core/operators/index.d.mts.map +1 -1
  34. package/dist/core/operators/index.mjs +1 -0
  35. package/dist/core/operators/index.mjs.map +1 -1
  36. package/dist/core/operators/keep-equal-parts.d.mts +85 -0
  37. package/dist/core/operators/keep-equal-parts.d.mts.map +1 -0
  38. package/dist/core/operators/keep-equal-parts.mjs +129 -0
  39. package/dist/core/operators/keep-equal-parts.mjs.map +1 -0
  40. package/dist/core/types/id.d.mts +6 -2
  41. package/dist/core/types/id.d.mts.map +1 -1
  42. package/dist/core/types/observable-family.d.mts +1 -0
  43. package/dist/core/types/observable-family.d.mts.map +1 -1
  44. package/dist/core/utils/id-maker.d.mts +0 -8
  45. package/dist/core/utils/id-maker.d.mts.map +1 -1
  46. package/dist/core/utils/id-maker.mjs +1 -11
  47. package/dist/core/utils/id-maker.mjs.map +1 -1
  48. package/dist/utils/create-reducer.d.mts +30 -1
  49. package/dist/utils/create-reducer.d.mts.map +1 -1
  50. package/dist/utils/create-reducer.mjs +8 -1
  51. package/dist/utils/create-reducer.mjs.map +1 -1
  52. package/dist/utils/create-state.d.mts +53 -1
  53. package/dist/utils/create-state.d.mts.map +1 -1
  54. package/dist/utils/create-state.mjs +52 -0
  55. package/dist/utils/create-state.mjs.map +1 -1
  56. package/package.json +10 -10
  57. package/src/core/class/child-observable-class.mts +15 -49
  58. package/src/core/class/create-child-observable.mts +8 -5
  59. package/src/core/class/manager-observable-class.mts +15 -134
  60. package/src/core/class/observable-base-class.mts +23 -2
  61. package/src/core/class/observable-node.mts +5 -11
  62. package/src/core/class/propagation-order.test.mts +107 -0
  63. package/src/core/class/propagation-state.mts +262 -0
  64. package/src/core/class/remove-completed.test.mts +2 -2
  65. package/src/core/class/update-loop.test.mts +104 -0
  66. package/src/core/create/empty.mts +42 -0
  67. package/src/core/create/empty.test.mts +65 -0
  68. package/src/core/create/index.mts +1 -0
  69. package/src/core/operators/index.mts +1 -0
  70. package/src/core/operators/keep-equal-parts.mts +167 -0
  71. package/src/core/operators/keep-equal-parts.test.mts +132 -0
  72. package/src/core/types/id.mts +6 -2
  73. package/src/core/types/observable-family.mts +5 -0
  74. package/src/core/utils/id-maker.mts +1 -12
  75. package/src/utils/create-reducer.mts +42 -2
  76. package/src/utils/create-state.mts +53 -1
  77. package/src/utils/create-state.test.mts +88 -0
@@ -0,0 +1,104 @@
1
+ import { createState } from '../../utils/index.mjs';
2
+ import { map } from '../operators/index.mjs';
3
+ import { maxUpdateNesting, updateLoopMessage } from './propagation-state.mjs';
4
+
5
+ describe('an update that keeps starting another inside itself', () => {
6
+ test('a subscriber that writes back to its own root throws a named error, not a stack overflow', () => {
7
+ const [a$, setA] = createState(0);
8
+
9
+ a$.subscribe((a) => {
10
+ if (a > 0) {
11
+ setA(a + 1);
12
+ }
13
+ });
14
+
15
+ withoutWarnings(() => {
16
+ expect(() => {
17
+ setA(1);
18
+ }).toThrow(updateLoopMessage);
19
+ });
20
+ });
21
+
22
+ test('two subscribers writing to each other’s roots throw the same error', () => {
23
+ const [a$, setA] = createState(0);
24
+
25
+ const [b$, setB] = createState(0);
26
+
27
+ a$.subscribe((a) => {
28
+ if (a > 0) {
29
+ setB(a + 1);
30
+ }
31
+ });
32
+
33
+ b$.subscribe((b) => {
34
+ if (b > 0) {
35
+ setA(b + 1);
36
+ }
37
+ });
38
+
39
+ withoutWarnings(() => {
40
+ expect(() => {
41
+ setA(1);
42
+ }).toThrow(updateLoopMessage);
43
+ });
44
+ });
45
+
46
+ test('updates work as before once the loop has thrown', () => {
47
+ const [a$, setA] = createState(0);
48
+
49
+ a$.subscribe((a) => {
50
+ if (a > 0) {
51
+ setA(a + 1);
52
+ }
53
+ });
54
+
55
+ withoutWarnings(() => {
56
+ expect(() => {
57
+ setA(1);
58
+ }).toThrow(updateLoopMessage);
59
+ });
60
+
61
+ const [b$, setB] = createState(0);
62
+
63
+ const mut_received: number[] = [];
64
+
65
+ b$.pipe(map((b) => b * 10)).subscribe((b) => {
66
+ mut_received.push(b);
67
+ });
68
+
69
+ setB(1);
70
+
71
+ setB(2);
72
+
73
+ assert.deepStrictEqual(mut_received, [0, 10, 20]);
74
+ });
75
+
76
+ test('a write that converges may nest up to the limit', () => {
77
+ const [a$, setA, { getSnapshot }] = createState(0);
78
+
79
+ a$.subscribe((a) => {
80
+ if (0 < a && a < maxUpdateNesting) {
81
+ setA(a + 1);
82
+ }
83
+ });
84
+
85
+ withoutWarnings(() => {
86
+ setA(1);
87
+ });
88
+
89
+ assert.strictEqual(getSnapshot(), maxUpdateNesting);
90
+ });
91
+ });
92
+
93
+ /**
94
+ * Runs `run` with `console.warn` silenced: every case here writes from a
95
+ * subscriber, which warns once per root.
96
+ */
97
+ const withoutWarnings = (run: () => void): void => {
98
+ vi.stubGlobal('console', { ...console, warn: vi.fn() });
99
+
100
+ run();
101
+
102
+ // cspell:disable-next-line
103
+ vi.unstubAllGlobals();
104
+ };
@@ -0,0 +1,42 @@
1
+ import { Optional } from 'ts-data-forge';
2
+ import { RootObservableClass } from '../class/index.mjs';
3
+ import type { RootObservable } from '../types/index.mjs';
4
+
5
+ /**
6
+ * Creates an Observable that holds no value and is already completed, so it
7
+ * never passes anything on (almost equivalent to RxJS `EMPTY`). Useful inside
8
+ * `switchMap` for the branch that should follow nothing, where a `source()`
9
+ * would offer a `next` that nothing calls.
10
+ *
11
+ * @template A - The type of the values it would carry
12
+ * @returns An Observable with no value that is already completed
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const nothing$ = empty<number>();
17
+ *
18
+ * const valueHistory: number[] = [];
19
+ *
20
+ * nothing$.subscribe(
21
+ * (value) => {
22
+ * valueHistory.push(value);
23
+ * },
24
+ * () => {
25
+ * // onComplete — called immediately since empty() completes right away
26
+ * },
27
+ * );
28
+ *
29
+ * assert.deepStrictEqual(valueHistory, []);
30
+ *
31
+ * assert.strictEqual(nothing$.isCompleted, true);
32
+ * ```
33
+ */
34
+ export const empty = <A,>(): RootObservable<A> => {
35
+ const obs = new RootObservableClass<A>({
36
+ initialValue: Optional.none,
37
+ });
38
+
39
+ obs.complete();
40
+
41
+ return obs;
42
+ };
@@ -0,0 +1,65 @@
1
+ import { Arr, Optional } from 'ts-data-forge';
2
+ import { createState } from '../../utils/index.mjs';
3
+ import { switchMap } from '../operators/index.mjs';
4
+ import { empty } from './empty.mjs';
5
+
6
+ describe(empty, () => {
7
+ test('holds no value and is already completed', () => {
8
+ const nothing$ = empty<number>();
9
+
10
+ assert.isTrue(nothing$.isCompleted);
11
+
12
+ assert.isTrue(Optional.isNone(nothing$.getSnapshot()));
13
+ });
14
+
15
+ test('passes nothing on to a subscriber, and completes it at once', () => {
16
+ const nothing$ = empty<number>();
17
+
18
+ const mut_seen: number[] = [];
19
+
20
+ const mut_completed = { value: false };
21
+
22
+ nothing$.subscribe(
23
+ (value) => {
24
+ mut_seen.push(value);
25
+ },
26
+ () => {
27
+ mut_completed.value = true;
28
+ },
29
+ );
30
+
31
+ assert.deepStrictEqual(mut_seen, []);
32
+
33
+ assert.isTrue(mut_completed.value);
34
+ });
35
+
36
+ test('switched to, it passes nothing on, and switchMap goes on', () => {
37
+ const [on$, setOn] = createState(true);
38
+
39
+ const [count$, setCount] = createState(0);
40
+
41
+ const followed$ = on$.pipe(
42
+ switchMap((isOn) => (isOn ? count$ : empty<number>())),
43
+ );
44
+
45
+ const mut_seen: number[] = [];
46
+
47
+ followed$.subscribe((value) => {
48
+ mut_seen.push(value);
49
+ });
50
+
51
+ setOn(false);
52
+
53
+ const seenBefore = Arr.length(mut_seen);
54
+
55
+ setCount(1);
56
+
57
+ assert.strictEqual(Arr.length(mut_seen), seenBefore);
58
+
59
+ setOn(true);
60
+
61
+ assert.deepStrictEqual(Arr.last(mut_seen), Optional.some(1));
62
+
63
+ assert.isFalse(followed$.isCompleted);
64
+ });
65
+ });
@@ -1,4 +1,5 @@
1
1
  export * from './counter.mjs';
2
+ export * from './empty.mjs';
2
3
  export * from './from-abortable-promise.mjs';
3
4
  export * from './from-promise.mjs';
4
5
  export * from './from-subscribable.mjs';
@@ -1,6 +1,7 @@
1
1
  export * from './audit.mjs';
2
2
  export * from './debounce.mjs';
3
3
  export * from './filter.mjs';
4
+ export * from './keep-equal-parts.mjs';
4
5
  export * from './map.mjs';
5
6
  export * from './merge-map.mjs';
6
7
  export * from './pairwise.mjs';
@@ -0,0 +1,167 @@
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 {
9
+ Optional,
10
+ replaceEqualDeep,
11
+ type ReplaceEqualDeepOptions,
12
+ } from 'ts-data-forge';
13
+ import { SyncChildObservableClass } from '../class/index.mjs';
14
+ import type {
15
+ KeepEqualPartsOperatorObservable,
16
+ KeepInitialValueOperator,
17
+ Observable,
18
+ UpdateToken,
19
+ } from '../types/index.mjs';
20
+
21
+ /**
22
+ * Passes on each value with every part deeply equal to the same part of the
23
+ * value passed on before replaced by that part, and skips a value deeply
24
+ * equal to it altogether (structural sharing). A reader of a part that did
25
+ * not change keeps the object it has, so `skipIfNoChange()` with `Object.is`
26
+ * stops it. Superset of `skipIfNoChange(fastDeepEqual)`.
27
+ *
28
+ * **Prefer keeping references where the value is written** — compare before
29
+ * `setState`, and skip it when nothing changed — so that nothing downstream
30
+ * has anything to undo. The `keepEqualParts` option of `createState` and
31
+ * `createReducer` does it for a state written whole, and `replaceEqualDeep`
32
+ * of ts-data-forge does it for any other write. This operator lets every
33
+ * update that changes nothing reach the source and everything up to it, and
34
+ * walks the whole value again on each one; reach for it when the source is
35
+ * not yours to change (a stream that arrives whole from outside).
36
+ *
37
+ * Array items are matched by index; pass `{ key }` to match them by key, an
38
+ * item for which `key` returns `undefined` being matched by index. Only plain
39
+ * objects and arrays are walked into; a `Map`, a `Set`, a `Date` or a class
40
+ * instance is compared and kept whole. The values must not be changed after
41
+ * they are passed on, because the parts kept are shared by later values.
42
+ *
43
+ * @template A - The type of values from the source
44
+ * @param options - `key`, to match array items by key rather than by index
45
+ * @returns An operator that keeps the parts that did not change
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * type Board = DeepReadonly<{
50
+ * title: string;
51
+ * cards: { id: number; text: string }[];
52
+ * }>;
53
+ *
54
+ * // A stream that arrives whole from outside (a WebSocket, say), each
55
+ * // message a new object even when nothing changed. A state you write
56
+ * // yourself takes `createState(initial, { keepEqualParts: true })`
57
+ * // instead.
58
+ * const message$ = source<Board>();
59
+ *
60
+ * const board$ = message$.pipe(keepEqualParts());
61
+ *
62
+ * const firstCard$ = board$
63
+ * .pipe(map((board) => board.cards[0]))
64
+ * .pipe(skipIfNoChange());
65
+ *
66
+ * const boardHistory: Board[] = [];
67
+ *
68
+ * board$.subscribe((board) => {
69
+ * boardHistory.push(board);
70
+ * });
71
+ *
72
+ * const firstCardHistory: unknown[] = [];
73
+ *
74
+ * firstCard$.subscribe((card) => {
75
+ * firstCardHistory.push(card);
76
+ * });
77
+ *
78
+ * message$.next({ title: 'todo', cards: [{ id: 1, text: 'a' }] });
79
+ *
80
+ * // Equal to the last one: nothing is passed on.
81
+ * message$.next({ title: 'todo', cards: [{ id: 1, text: 'a' }] });
82
+ *
83
+ * assert.strictEqual(boardHistory.length, 1);
84
+ *
85
+ * // A card added: a new board, but the first card is the one passed on
86
+ * // before, so `firstCard$` is not told of it.
87
+ * message$.next({
88
+ * title: 'todo',
89
+ * cards: [
90
+ * { id: 1, text: 'a' },
91
+ * { id: 2, text: 'b' },
92
+ * ],
93
+ * });
94
+ *
95
+ * assert.strictEqual(boardHistory.length, 2);
96
+ *
97
+ * assert.strictEqual(boardHistory[1]?.cards[0], boardHistory[0]?.cards[0]);
98
+ *
99
+ * assert.strictEqual(firstCardHistory.length, 1);
100
+ * ```
101
+ */
102
+ export const keepEqualParts = <A,>(
103
+ options?: ReplaceEqualDeepOptions,
104
+ ): KeepInitialValueOperator<A, A> =>
105
+ // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
106
+ ((parentObservable) =>
107
+ new KeepEqualPartsObservableClass(
108
+ parentObservable,
109
+ options,
110
+ )) as KeepInitialValueOperator<A, A>;
111
+
112
+ class KeepEqualPartsObservableClass<A>
113
+ extends SyncChildObservableClass<A, readonly [A]>
114
+ implements KeepEqualPartsOperatorObservable<A>
115
+ {
116
+ readonly #options: ReplaceEqualDeepOptions | undefined;
117
+ #mut_previousValue: Optional<A>;
118
+
119
+ constructor(
120
+ parentObservable: Observable<A>,
121
+ options: ReplaceEqualDeepOptions | undefined,
122
+ ) {
123
+ super({
124
+ parents: [parentObservable],
125
+ initialValue: parentObservable.getSnapshot(),
126
+ });
127
+
128
+ this.#mut_previousValue = parentObservable.getSnapshot();
129
+
130
+ this.#options = options;
131
+
132
+ this.attach();
133
+ }
134
+
135
+ override tryUpdate(updateToken: UpdateToken): void {
136
+ const par = this.parents[0];
137
+
138
+ const sn = par.getSnapshot();
139
+
140
+ if (par.updateToken !== updateToken || Optional.isNone(sn)) {
141
+ return; // skip update
142
+ }
143
+
144
+ const prev = this.#mut_previousValue;
145
+
146
+ if (Optional.isNone(prev)) {
147
+ this.#mut_previousValue = sn;
148
+
149
+ this.setNext(sn.value, updateToken);
150
+
151
+ return;
152
+ }
153
+
154
+ const kept = replaceEqualDeep(prev.value, sn.value, this.#options);
155
+
156
+ if (Object.is(kept, prev.value)) {
157
+ return;
158
+ }
159
+
160
+ // Updated before `setNext`, which can lead back here.
161
+ this.#mut_previousValue = Object.is(kept, sn.value)
162
+ ? sn
163
+ : Optional.some(kept);
164
+
165
+ this.setNext(kept, updateToken);
166
+ }
167
+ }
@@ -0,0 +1,132 @@
1
+ import { hasKey, isRecord, Optional } from 'ts-data-forge';
2
+ import { source } from '../create/index.mjs';
3
+ import { keepEqualParts } from './keep-equal-parts.mjs';
4
+ import { map } from './map.mjs';
5
+ import { skipIfNoChange } from './skip-if-no-change.mjs';
6
+
7
+ type Report = Readonly<{
8
+ readAt: number;
9
+ summary: Readonly<{ open: number }>;
10
+ entries: readonly Readonly<{ number: number; title: string }>[];
11
+ }>;
12
+
13
+ describe(keepEqualParts, () => {
14
+ test('passes on nothing when the next value is deeply equal to the last one', () => {
15
+ const report$ = source<Report>();
16
+
17
+ const kept$ = report$.pipe(keepEqualParts());
18
+
19
+ const seen = record(kept$);
20
+
21
+ report$.next(report(1, [entry(1, 'a')]));
22
+
23
+ report$.next(report(1, [entry(1, 'a')]));
24
+
25
+ assert.strictEqual(seen.length, 1);
26
+ });
27
+
28
+ test('passes on a new root whose unchanged branches are the ones passed on before', () => {
29
+ const report$ = source<Report>();
30
+
31
+ const kept$ = report$.pipe(keepEqualParts());
32
+
33
+ const seen = record(kept$);
34
+
35
+ report$.next(report(1, [entry(1, 'a'), entry(2, 'b')]));
36
+
37
+ report$.next(report(2, [entry(1, 'a'), entry(2, 'b (edited)')]));
38
+
39
+ assert.strictEqual(seen.length, 2);
40
+
41
+ const [first, second] = seen;
42
+
43
+ assert.notStrictEqual(second, first);
44
+
45
+ assert.strictEqual(second?.summary, first?.summary);
46
+
47
+ assert.strictEqual(second?.entries[0], first?.entries[0]);
48
+
49
+ assert.notStrictEqual(second?.entries[1], first?.entries[1]);
50
+
51
+ assert.strictEqual(second?.readAt, 2);
52
+ });
53
+
54
+ test('matches array items by `key`', () => {
55
+ const report$ = source<Report>();
56
+
57
+ const kept$ = report$.pipe(
58
+ keepEqualParts({
59
+ key: (item) =>
60
+ isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
61
+ }),
62
+ );
63
+
64
+ const seen = record(kept$);
65
+
66
+ report$.next(report(1, [entry(1, 'a')]));
67
+
68
+ report$.next(report(1, [entry(0, 'new'), entry(1, 'a')]));
69
+
70
+ const [first, second] = seen;
71
+
72
+ assert.strictEqual(second?.entries[1], first?.entries[0]);
73
+ });
74
+
75
+ test('keeps the initial value of an initialized parent', () => {
76
+ const initial = report(1, []);
77
+
78
+ const report$ = source<Report>(initial);
79
+
80
+ const kept$ = report$.pipe(keepEqualParts());
81
+
82
+ assert.strictEqual(
83
+ Optional.unwrapOr(kept$.getSnapshot(), undefined),
84
+ initial,
85
+ );
86
+
87
+ const seen = record(kept$);
88
+
89
+ report$.next(report(1, []));
90
+
91
+ assert.deepStrictEqual(seen, [initial]);
92
+ });
93
+
94
+ test('lets `skipIfNoChange()` stop a part that did not change', () => {
95
+ const report$ = source<Report>(report(1, [entry(1, 'a')]));
96
+
97
+ const summaries = record(
98
+ report$
99
+ .pipe(keepEqualParts())
100
+ .pipe(map((held) => held.summary))
101
+ .pipe(skipIfNoChange()),
102
+ );
103
+
104
+ report$.next(report(2, [entry(1, 'a')]));
105
+
106
+ assert.strictEqual(summaries.length, 1);
107
+ });
108
+ });
109
+
110
+ const entry = (
111
+ number: number,
112
+ title: string,
113
+ ): Readonly<{ number: number; title: string }> => ({ number, title }) as const;
114
+
115
+ const report = (readAt: number, entries: Report['entries']): Report =>
116
+ ({
117
+ readAt,
118
+ summary: { open: entries.length },
119
+ entries,
120
+ }) as const;
121
+
122
+ const record = <A,>(
123
+ observable: Readonly<{ subscribe: (onNext: (value: A) => void) => unknown }>,
124
+ ): readonly A[] => {
125
+ const mut_seen: A[] = [];
126
+
127
+ observable.subscribe((value) => {
128
+ mut_seen.push(value);
129
+ });
130
+
131
+ return mut_seen;
132
+ };
@@ -1,3 +1,7 @@
1
- import type { Brand } from 'ts-type-forge';
1
+ import type { Brand, UnknownRecord } from 'ts-type-forge';
2
2
 
3
- export type UpdateToken = Brand<symbol, 'update-token'>;
3
+ /**
4
+ * Identifies one update. Opaque: an object issued per update, which is also
5
+ * that update's queue.
6
+ */
7
+ export type UpdateToken = Brand<UnknownRecord, 'update-token'>;
@@ -244,6 +244,11 @@ export type WithBufferedFromOperatorObservable<A, B> = SyncChildObservable<
244
244
 
245
245
  export type FilterOperatorObservable<A> = SyncChildObservable<A, readonly [A]>;
246
246
 
247
+ export type KeepEqualPartsOperatorObservable<A> = SyncChildObservable<
248
+ A,
249
+ readonly [A]
250
+ >;
251
+
247
252
  export type SkipIfNoChangeOperatorObservable<A> = SyncChildObservable<
248
253
  A,
249
254
  readonly [A]
@@ -1,16 +1,5 @@
1
1
  import type { UpdateToken } from '../types/index.mjs';
2
2
 
3
- /**
4
- * The token of a new update, which the update's root or async child passes to
5
- * every node it reaches. A `Symbol` with no description, issued once per
6
- * update and never per node, so its cost is spread over every node the
7
- * update reaches. Unique without any counter, so the tokens of two copies of
8
- * synstate in one program never coincide, and nothing is kept in a global.
9
- */
10
- export const issueUpdateToken = (): UpdateToken =>
11
- // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
12
- Symbol() as UpdateToken;
13
-
14
3
  /**
15
4
  * The token a node holds before its first update: one value shared by every
16
5
  * node of this copy, since no update is ever issued it, so creating a node
@@ -18,4 +7,4 @@ export const issueUpdateToken = (): UpdateToken =>
18
7
  */
19
8
  export const notUpdatedYet: UpdateToken =
20
9
  // @sumi-expect-error banned-syntax/no-unsafe-type-assertion
21
- Symbol('synstate/not-updated-yet') as UpdateToken;
10
+ Object.freeze({}) as UpdateToken;
@@ -1,3 +1,4 @@
1
+ import { replaceEqualDeep, type ReplaceEqualDeepOptions } from 'ts-data-forge';
1
2
  import type { Reducer } from 'ts-type-forge';
2
3
  import { source, type InitializedObservable } from '../core/index.mjs';
3
4
 
@@ -10,7 +11,8 @@ import { source, type InitializedObservable } from '../core/index.mjs';
10
11
  * @param reducer - A pure function that takes current state and action, returns new state
11
12
  * @param initialState - The initial value of the state
12
13
  * @param options - {@link StateOptions}: `equals` (default `Object.is`),
13
- * to pass on only the dispatches that change the state
14
+ * to pass on only the dispatches that change the state, and
15
+ * `keepEqualParts`, to keep the parts of the state a dispatch did not change
14
16
  * @returns A 3-element tuple: `[state, dispatch, { getSnapshot, initialState }]`.
15
17
  * `dispatch` returns the state it leaves; once `state` is completed, that
16
18
  * is the state held.
@@ -93,10 +95,20 @@ export const createReducer = <S, A>(
93
95
 
94
96
  const equals = options?.equals ?? Object.is;
95
97
 
98
+ const keepEqualParts = options?.keepEqualParts ?? false;
99
+
100
+ const keepOptions =
101
+ typeof keepEqualParts === 'boolean' ? undefined : keepEqualParts;
102
+
96
103
  const dispatch = (action: A): S => {
97
104
  const currentState = state.getSnapshot().value;
98
105
 
99
- const nextState = reducer(currentState, action);
106
+ const reduced = reducer(currentState, action);
107
+
108
+ const nextState =
109
+ keepEqualParts === false
110
+ ? reduced
111
+ : replaceEqualDeep(currentState, reduced, keepOptions);
100
112
 
101
113
  // Once completed, `next` keeps the state held and returns it.
102
114
  return equals(currentState, nextState)
@@ -131,4 +143,32 @@ export type StateOptions<S> = Readonly<{
131
143
  * @param next - The state the update would set
132
144
  */
133
145
  equals?: (current: S, next: S) => boolean;
146
+
147
+ /**
148
+ * Whether an update keeps the parts of the state it did not change. With
149
+ * `true`, the next state is compared with the current one before it is
150
+ * written, and every part of it deeply equal to the same part of the
151
+ * current state is replaced by that part (`replaceEqualDeep` of
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
154
+ * deeply equal, the current state is kept and nothing is passed on, so
155
+ * `equals` can stay `Object.is`.
156
+ *
157
+ * This is for a state written whole from a source that answers with a new
158
+ * object every time, a server polled for a report, say. Array items are
159
+ * matched by index; pass `{ key }` to match them by key, an item for which
160
+ * `key` returns `undefined` being matched by index. Only plain objects and
161
+ * arrays are walked into; a `Map`, a `Set`, a `Date` or a class instance is
162
+ * compared and kept whole.
163
+ *
164
+ * Prefer this over `keepEqualParts()` downstream: keeping the parts where
165
+ * the state is written means no update with nothing in it is passed on at
166
+ * all, and nothing downstream has anything to undo.
167
+ *
168
+ * Defaults to `false`: the next state is written as given. With it on,
169
+ * each update compares deeply every part of the next state that is not
170
+ * the very object of the current one; a state whose updates already keep
171
+ * what they do not change, as `{ ...prev, x }` does, gains nothing from it.
172
+ */
173
+ keepEqualParts?: boolean | ReplaceEqualDeepOptions;
134
174
  }>;