synstate 4.1.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.
- package/README.md +58 -1
- package/dist/core/class/child-observable-class.d.mts +4 -7
- package/dist/core/class/child-observable-class.d.mts.map +1 -1
- package/dist/core/class/child-observable-class.mjs +8 -25
- package/dist/core/class/child-observable-class.mjs.map +1 -1
- package/dist/core/class/create-child-observable.d.mts +8 -5
- package/dist/core/class/create-child-observable.d.mts.map +1 -1
- package/dist/core/class/create-child-observable.mjs.map +1 -1
- package/dist/core/class/manager-observable-class.d.mts +4 -15
- package/dist/core/class/manager-observable-class.d.mts.map +1 -1
- package/dist/core/class/manager-observable-class.mjs +5 -91
- package/dist/core/class/manager-observable-class.mjs.map +1 -1
- package/dist/core/class/observable-base-class.d.mts +9 -2
- package/dist/core/class/observable-base-class.d.mts.map +1 -1
- package/dist/core/class/observable-base-class.mjs +17 -0
- package/dist/core/class/observable-base-class.mjs.map +1 -1
- package/dist/core/class/observable-node.d.mts +4 -8
- package/dist/core/class/observable-node.d.mts.map +1 -1
- package/dist/core/class/observable-node.mjs.map +1 -1
- package/dist/core/class/propagation-state.d.mts +26 -0
- package/dist/core/class/propagation-state.d.mts.map +1 -1
- package/dist/core/class/propagation-state.mjs +190 -0
- package/dist/core/class/propagation-state.mjs.map +1 -1
- package/dist/core/operators/index.d.mts +1 -0
- package/dist/core/operators/index.d.mts.map +1 -1
- package/dist/core/operators/index.mjs +1 -0
- package/dist/core/operators/index.mjs.map +1 -1
- package/dist/core/operators/keep-equal-parts.d.mts +85 -0
- package/dist/core/operators/keep-equal-parts.d.mts.map +1 -0
- package/dist/core/operators/keep-equal-parts.mjs +129 -0
- package/dist/core/operators/keep-equal-parts.mjs.map +1 -0
- package/dist/core/types/id.d.mts +6 -2
- package/dist/core/types/id.d.mts.map +1 -1
- package/dist/core/types/observable-family.d.mts +1 -0
- package/dist/core/types/observable-family.d.mts.map +1 -1
- package/dist/core/utils/id-maker.d.mts +0 -8
- package/dist/core/utils/id-maker.d.mts.map +1 -1
- package/dist/core/utils/id-maker.mjs +1 -11
- package/dist/core/utils/id-maker.mjs.map +1 -1
- package/dist/utils/create-reducer.d.mts +30 -1
- package/dist/utils/create-reducer.d.mts.map +1 -1
- package/dist/utils/create-reducer.mjs +8 -1
- package/dist/utils/create-reducer.mjs.map +1 -1
- package/dist/utils/create-state.d.mts +53 -1
- package/dist/utils/create-state.d.mts.map +1 -1
- package/dist/utils/create-state.mjs +52 -0
- package/dist/utils/create-state.mjs.map +1 -1
- package/package.json +9 -9
- package/src/core/class/child-observable-class.mts +15 -49
- package/src/core/class/create-child-observable.mts +8 -5
- package/src/core/class/manager-observable-class.mts +15 -134
- package/src/core/class/observable-base-class.mts +23 -2
- package/src/core/class/observable-node.mts +5 -11
- package/src/core/class/propagation-order.test.mts +107 -0
- package/src/core/class/propagation-state.mts +262 -0
- package/src/core/class/remove-completed.test.mts +2 -2
- package/src/core/class/update-loop.test.mts +104 -0
- package/src/core/operators/index.mts +1 -0
- package/src/core/operators/keep-equal-parts.mts +167 -0
- package/src/core/operators/keep-equal-parts.test.mts +132 -0
- package/src/core/types/id.mts +6 -2
- package/src/core/types/observable-family.mts +5 -0
- package/src/core/utils/id-maker.mts +1 -12
- package/src/utils/create-reducer.mts +42 -2
- package/src/utils/create-state.mts +53 -1
- package/src/utils/create-state.test.mts +88 -0
|
@@ -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
|
+
};
|
package/src/core/types/id.mts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
import type { Brand } from 'ts-type-forge';
|
|
1
|
+
import type { Brand, UnknownRecord } from 'ts-type-forge';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}>;
|
|
@@ -29,7 +29,8 @@ const reducer = <S,>(state: S, action: Action<S>): S => {
|
|
|
29
29
|
* @template S - The type of the state
|
|
30
30
|
* @param initialState - The initial value of the state
|
|
31
31
|
* @param options - {@link StateOptions}: `equals` (default `Object.is`),
|
|
32
|
-
* to pass on only the updates that change the state
|
|
32
|
+
* to pass on only the updates that change the state, and `keepEqualParts`,
|
|
33
|
+
* to keep the parts of the state an update did not change
|
|
33
34
|
* @returns A 3-element tuple: `[state, setState, { updateState, resetState, getSnapshot, initialState }]`
|
|
34
35
|
*
|
|
35
36
|
* @example
|
|
@@ -89,6 +90,57 @@ const reducer = <S,>(state: S, action: Action<S>): S => {
|
|
|
89
90
|
* ]);
|
|
90
91
|
* ```
|
|
91
92
|
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* type PullReport = DeepReadonly<{
|
|
96
|
+
* summary: { open: number };
|
|
97
|
+
* pulls: { number: number; title: string }[];
|
|
98
|
+
* }>;
|
|
99
|
+
*
|
|
100
|
+
* const [report, setReport] = createState<PullReport>(
|
|
101
|
+
* { summary: { open: 0 }, pulls: [] },
|
|
102
|
+
* { keepEqualParts: true },
|
|
103
|
+
* );
|
|
104
|
+
*
|
|
105
|
+
* const summary = report.pipe(map((r) => r.summary)).pipe(skipIfNoChange());
|
|
106
|
+
*
|
|
107
|
+
* const summaryHistory: unknown[] = [];
|
|
108
|
+
*
|
|
109
|
+
* summary.subscribe((value) => {
|
|
110
|
+
* summaryHistory.push(value);
|
|
111
|
+
* });
|
|
112
|
+
*
|
|
113
|
+
* // A server answers each poll with the whole report, a new object every
|
|
114
|
+
* // time. What did not change keeps the object already held.
|
|
115
|
+
* setReport({
|
|
116
|
+
* summary: { open: 1 },
|
|
117
|
+
* pulls: [{ number: 1, title: 'one' }],
|
|
118
|
+
* });
|
|
119
|
+
*
|
|
120
|
+
* const held = report.getSnapshot().value;
|
|
121
|
+
*
|
|
122
|
+
* // An answer that changes nothing is not passed on at all.
|
|
123
|
+
* setReport({
|
|
124
|
+
* summary: { open: 1 },
|
|
125
|
+
* pulls: [{ number: 1, title: 'one' }],
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* assert.strictEqual(report.getSnapshot().value, held);
|
|
129
|
+
*
|
|
130
|
+
* // An edited title: a new report and a new array, while the summary is
|
|
131
|
+
* // the one held, so `summary` is not told of it.
|
|
132
|
+
* setReport({
|
|
133
|
+
* summary: { open: 1 },
|
|
134
|
+
* pulls: [{ number: 1, title: 'one (edited)' }],
|
|
135
|
+
* });
|
|
136
|
+
*
|
|
137
|
+
* assert.notStrictEqual(report.getSnapshot().value, held);
|
|
138
|
+
*
|
|
139
|
+
* assert.strictEqual(report.getSnapshot().value.summary, held.summary);
|
|
140
|
+
*
|
|
141
|
+
* assert.deepStrictEqual(summaryHistory, [{ open: 0 }, { open: 1 }]);
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
92
144
|
* @note Do not call `setState`, `updateState` or `resetState` from a subscriber or a projection while another
|
|
93
145
|
* update is propagating: the write propagates immediately, so subscribers can
|
|
94
146
|
* see an intermediate state, and it logs a `console.warn` (once per
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasKey, isRecord } from 'ts-data-forge';
|
|
1
2
|
import { createState } from './create-state.mjs';
|
|
2
3
|
|
|
3
4
|
describe(createState, () => {
|
|
@@ -75,6 +76,93 @@ describe(createState, () => {
|
|
|
75
76
|
|
|
76
77
|
assert.deepStrictEqual(seen, [held]);
|
|
77
78
|
});
|
|
79
|
+
|
|
80
|
+
describe('keepEqualParts', () => {
|
|
81
|
+
type Report = Readonly<{
|
|
82
|
+
summary: Readonly<{ open: number }>;
|
|
83
|
+
entries: readonly Readonly<{ number: number; title: string }>[];
|
|
84
|
+
}>;
|
|
85
|
+
|
|
86
|
+
const initial: Report = {
|
|
87
|
+
summary: { open: 1 },
|
|
88
|
+
entries: [{ number: 1, title: 'a' }],
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
test('passes nothing on, and keeps the current object, when the next state is deeply equal', () => {
|
|
92
|
+
const [state, setState, { getSnapshot }] = createState(initial, {
|
|
93
|
+
keepEqualParts: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const seen = record(state);
|
|
97
|
+
|
|
98
|
+
assert.strictEqual(
|
|
99
|
+
setState({
|
|
100
|
+
summary: { open: 1 },
|
|
101
|
+
entries: [{ number: 1, title: 'a' }],
|
|
102
|
+
}),
|
|
103
|
+
initial,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(getSnapshot(), initial);
|
|
107
|
+
|
|
108
|
+
assert.deepStrictEqual(seen, [initial]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('writes a new state whose unchanged branches are the current ones', () => {
|
|
112
|
+
const [state, setState] = createState(initial, { keepEqualParts: true });
|
|
113
|
+
|
|
114
|
+
const seen = record(state);
|
|
115
|
+
|
|
116
|
+
const written = setState({
|
|
117
|
+
summary: { open: 1 },
|
|
118
|
+
entries: [
|
|
119
|
+
{ number: 1, title: 'a' },
|
|
120
|
+
{ number: 2, title: 'b' },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.deepStrictEqual(seen, [initial, written]);
|
|
125
|
+
|
|
126
|
+
assert.strictEqual(written.summary, initial.summary);
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(written.entries[0], initial.entries[0]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('applies to `updateState` and matches items by `key`', () => {
|
|
132
|
+
const [state, , { updateState }] = createState(initial, {
|
|
133
|
+
keepEqualParts: {
|
|
134
|
+
key: (item) =>
|
|
135
|
+
isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A whole new object, as a server answer would be.
|
|
140
|
+
const written = updateState(() => ({
|
|
141
|
+
summary: { open: 1 },
|
|
142
|
+
entries: [
|
|
143
|
+
{ number: 0, title: 'new' },
|
|
144
|
+
{ number: 1, title: 'a' },
|
|
145
|
+
],
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
assert.strictEqual(written.summary, initial.summary);
|
|
149
|
+
|
|
150
|
+
assert.strictEqual(written.entries[1], initial.entries[0]);
|
|
151
|
+
|
|
152
|
+
assert.strictEqual(state.getSnapshot().value, written);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('is off by default', () => {
|
|
156
|
+
const [, setState] = createState(initial);
|
|
157
|
+
|
|
158
|
+
const next: Report = {
|
|
159
|
+
summary: { open: 1 },
|
|
160
|
+
entries: [{ number: 1, title: 'a' }],
|
|
161
|
+
} as const;
|
|
162
|
+
|
|
163
|
+
assert.strictEqual(setState(next), next);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
78
166
|
});
|
|
79
167
|
|
|
80
168
|
const record = <A,>(
|