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.
- 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/create/empty.d.mts +32 -0
- package/dist/core/create/empty.d.mts.map +1 -0
- package/dist/core/create/empty.mjs +39 -0
- package/dist/core/create/empty.mjs.map +1 -0
- package/dist/core/create/index.d.mts +1 -0
- package/dist/core/create/index.d.mts.map +1 -1
- package/dist/core/create/index.mjs +1 -0
- package/dist/core/create/index.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 +10 -10
- 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/create/empty.mts +42 -0
- package/src/core/create/empty.test.mts +65 -0
- package/src/core/create/index.mts +1 -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,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
|
+
});
|
|
@@ -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
|
}>;
|