synstate 6.0.0 → 6.1.1
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 +4 -3
- package/dist/core/class/manager-observable-class.d.mts.map +1 -1
- package/dist/core/class/manager-observable-class.mjs +6 -0
- package/dist/core/class/manager-observable-class.mjs.map +1 -1
- package/dist/core/class/observable-base-class.d.mts +8 -0
- package/dist/core/class/observable-base-class.d.mts.map +1 -1
- package/dist/core/class/observable-base-class.mjs +16 -1
- package/dist/core/class/observable-base-class.mjs.map +1 -1
- package/dist/core/class/propagation-state.d.mts +6 -3
- package/dist/core/class/propagation-state.d.mts.map +1 -1
- package/dist/core/class/propagation-state.mjs +4 -0
- package/dist/core/class/propagation-state.mjs.map +1 -1
- package/dist/core/combine/combine.d.mts.map +1 -1
- package/dist/core/combine/combine.mjs +3 -43
- package/dist/core/combine/combine.mjs.map +1 -1
- package/dist/core/combine/derive.d.mts +63 -0
- package/dist/core/combine/derive.d.mts.map +1 -0
- package/dist/core/combine/derive.mjs +124 -0
- package/dist/core/combine/derive.mjs.map +1 -0
- package/dist/core/combine/index.d.mts +1 -0
- package/dist/core/combine/index.d.mts.map +1 -1
- package/dist/core/combine/index.mjs +1 -0
- package/dist/core/combine/index.mjs.map +1 -1
- package/dist/core/types/id.d.mts +2 -1
- package/dist/core/types/id.d.mts.map +1 -1
- package/dist/core/types/observable-family.d.mts +3 -0
- package/dist/core/types/observable-family.d.mts.map +1 -1
- package/dist/core/types/observable-family.mjs.map +1 -1
- package/dist/core/types/observable.d.mts +2 -1
- package/dist/core/types/observable.d.mts.map +1 -1
- package/dist/core/types/observable.mjs.map +1 -1
- package/dist/core/utils/id-maker.d.mts +7 -0
- package/dist/core/utils/id-maker.d.mts.map +1 -1
- package/dist/core/utils/id-maker.mjs +9 -0
- package/dist/core/utils/id-maker.mjs.map +1 -1
- package/dist/utils/create-reducer.d.mts +2 -2
- package/dist/utils/create-reducer.d.mts.map +1 -1
- package/dist/utils/create-reducer.mjs +4 -16
- package/dist/utils/create-reducer.mjs.map +1 -1
- package/dist/utils/create-state.d.mts +1 -1
- package/dist/utils/create-state.d.mts.map +1 -1
- package/dist/utils/create-state.mjs +10 -13
- package/dist/utils/create-state.mjs.map +1 -1
- package/dist/utils/state-source.d.mts +19 -0
- package/dist/utils/state-source.d.mts.map +1 -0
- package/dist/utils/state-source.mjs +31 -0
- package/dist/utils/state-source.mjs.map +1 -0
- package/package.json +13 -11
- package/src/core/class/manager-observable-class.mts +7 -0
- package/src/core/class/observable-base-class.mts +22 -2
- package/src/core/class/propagation-state.mts +11 -7
- package/src/core/class/unobserved-write.test.mts +194 -0
- package/src/core/combine/combine.mts +7 -72
- package/src/core/combine/derive.mts +172 -0
- package/src/core/combine/derive.test.mts +190 -0
- package/src/core/combine/index.mts +1 -0
- package/src/core/types/id.mts +2 -1
- package/src/core/types/observable-family.mts +18 -0
- package/src/core/types/observable.mts +2 -1
- package/src/core/utils/id-maker.mts +10 -0
- package/src/utils/create-reducer.mts +9 -24
- package/src/utils/create-state.mts +12 -29
- package/src/utils/create-state.test.mts +18 -0
- package/src/utils/state-source.mts +48 -0
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
import { Arr, expectType, Optional } from 'ts-data-forge';
|
|
8
|
-
import type { NonEmptyTuple, Tuple } from 'ts-type-forge';
|
|
9
|
-
import { SyncChildObservableClass } from '../class/index.mjs';
|
|
1
|
+
import { expectType } from 'ts-data-forge';
|
|
2
|
+
import type { NonEmptyTuple } from 'ts-type-forge';
|
|
10
3
|
import { source } from '../create/index.mjs';
|
|
11
4
|
import { withInitialValue } from '../operators/index.mjs';
|
|
12
5
|
import type {
|
|
@@ -14,12 +7,10 @@ import type {
|
|
|
14
7
|
CombineObservableRefined,
|
|
15
8
|
InitializedObservable,
|
|
16
9
|
InitializedSyncChildObservable,
|
|
17
|
-
NonEmptyUnknownList,
|
|
18
10
|
Observable,
|
|
19
11
|
SyncChildObservable,
|
|
20
|
-
UpdateToken,
|
|
21
|
-
Wrap,
|
|
22
12
|
} from '../types/index.mjs';
|
|
13
|
+
import { derive } from './derive.mjs';
|
|
23
14
|
|
|
24
15
|
/**
|
|
25
16
|
* Combines multiple observables into a single observable that emits an array of their latest values.
|
|
@@ -81,9 +72,12 @@ import type {
|
|
|
81
72
|
export const combine = <const OS extends NonEmptyTuple<Observable<unknown>>>(
|
|
82
73
|
parents: OS,
|
|
83
74
|
): CombineObservableRefined<OS> =>
|
|
75
|
+
// `derive` collecting the values into a new array on every update, which is
|
|
76
|
+
// never `Object.is` the one before, so every update is passed on.
|
|
84
77
|
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
85
|
-
|
|
78
|
+
derive(
|
|
86
79
|
parents,
|
|
80
|
+
(...values) => values,
|
|
87
81
|
) as unknown as CombineObservableRefined<OS>;
|
|
88
82
|
|
|
89
83
|
/**
|
|
@@ -92,65 +86,6 @@ export const combine = <const OS extends NonEmptyTuple<Observable<unknown>>>(
|
|
|
92
86
|
*/
|
|
93
87
|
export const combineLatest = combine;
|
|
94
88
|
|
|
95
|
-
class CombineObservableClass<const A extends NonEmptyUnknownList>
|
|
96
|
-
extends SyncChildObservableClass<A, A>
|
|
97
|
-
implements CombineObservable<A>
|
|
98
|
-
{
|
|
99
|
-
constructor(parents: Wrap<A>) {
|
|
100
|
-
const parentsValues: Tuple.MapTo<Optional<A[number]>, A> = Arr.map(
|
|
101
|
-
parents,
|
|
102
|
-
(p) => p.getSnapshot(),
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// The annotation on `initialValue` is what replaces the type assertion.
|
|
106
|
-
// `Arr.map` carries a single result element type, so it reports `A[number]`
|
|
107
|
-
// at every position, and `Tuple.MapTo<A[number], A>` — the uniform mapping
|
|
108
|
-
// — is *not* assignable back to `A`. The positional
|
|
109
|
-
// `Readonly<{ [K in keyof A]: A[K] }>` is, and TypeScript accepts the
|
|
110
|
-
// uniform result into it because the two mapped types share `keyof A`. So
|
|
111
|
-
// it is the annotation, not an assertion, that records the position-wise
|
|
112
|
-
// fact the checker cannot derive on its own.
|
|
113
|
-
const initialValue: Optional<Readonly<{ [K in keyof A]: A[K] }>> =
|
|
114
|
-
Arr.every(parentsValues, Optional.isSome)
|
|
115
|
-
? Optional.some(Arr.map(parentsValues, (c) => c.value))
|
|
116
|
-
: Optional.none;
|
|
117
|
-
|
|
118
|
-
super({
|
|
119
|
-
parents,
|
|
120
|
-
initialValue,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
this.attach();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
override tryUpdate(updateToken: UpdateToken): void {
|
|
127
|
-
const parents = this.parents;
|
|
128
|
-
|
|
129
|
-
if (parents.every((o) => o.updateToken !== updateToken)) {
|
|
130
|
-
return; // all parents are skipped
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Same shape-preserving chain as the initial value: `Arr.map` (not the
|
|
134
|
-
// native one, which drops the tuple shape) into the uniform
|
|
135
|
-
// `Tuple.MapTo`, then a positional annotation to get back to `A`.
|
|
136
|
-
const parentValues: Tuple.MapTo<Optional<A[number]>, A> = Arr.map(
|
|
137
|
-
parents,
|
|
138
|
-
(a) => a.getSnapshot(),
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
if (!Arr.every(parentValues, Optional.isSome)) {
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const nextValue: Readonly<{ [K in keyof A]: A[K] }> = Arr.map(
|
|
146
|
-
parentValues,
|
|
147
|
-
(a) => a.value,
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
this.setNext(nextValue, updateToken);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
89
|
if (import.meta.vitest !== undefined) {
|
|
155
90
|
test('type test', () => {
|
|
156
91
|
expect(1).toBe(1); // dummy
|
|
@@ -0,0 +1,172 @@
|
|
|
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 { Arr, Optional } from 'ts-data-forge';
|
|
9
|
+
import type { NonEmptyTuple } from 'ts-type-forge';
|
|
10
|
+
import { SyncChildObservableClass } from '../class/index.mjs';
|
|
11
|
+
import type {
|
|
12
|
+
DeriveObservable,
|
|
13
|
+
DeriveObservableRefined,
|
|
14
|
+
NonEmptyUnknownList,
|
|
15
|
+
Observable,
|
|
16
|
+
Unwrap,
|
|
17
|
+
UpdateToken,
|
|
18
|
+
Wrap,
|
|
19
|
+
} from '../types/index.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derives one value from the latest values of several observables: one
|
|
23
|
+
* observable that calls `project` with them, as arguments, and emits the
|
|
24
|
+
* result unless it equals the one already held.
|
|
25
|
+
*
|
|
26
|
+
* It emits once every parent has a value, and then whenever any of them
|
|
27
|
+
* emits, as `combine` does; but where `combine(parents).pipe(map(fn))` is two
|
|
28
|
+
* observables and a tuple per update, `derive` is one, and passes the values
|
|
29
|
+
* as arguments (a rest parameter in `project` collects them into an array
|
|
30
|
+
* again).
|
|
31
|
+
* The comparison is `Object.is` unless `equals` says otherwise.
|
|
32
|
+
*
|
|
33
|
+
* @template OS - Tuple type of the parent observables
|
|
34
|
+
* @template B - The type of the derived value
|
|
35
|
+
* @param parents - The observables to derive from
|
|
36
|
+
* @param project - Computes the value from the parents' values, in order
|
|
37
|
+
* @param options - `equals` compares a result with the one held
|
|
38
|
+
* @returns An observable of the derived value, initialized when every parent is
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* // Timeline:
|
|
43
|
+
* //
|
|
44
|
+
* // price$ 100 120
|
|
45
|
+
* // quantity$ 2 3
|
|
46
|
+
* // total$ 200 240 360
|
|
47
|
+
* //
|
|
48
|
+
* // Explanation:
|
|
49
|
+
* // - derive waits for every parent to have a value
|
|
50
|
+
* // - Then calls the projection with their latest values whenever one emits
|
|
51
|
+
*
|
|
52
|
+
* const price$ = source<number>();
|
|
53
|
+
*
|
|
54
|
+
* const quantity$ = source<number>();
|
|
55
|
+
*
|
|
56
|
+
* const total$ = derive(
|
|
57
|
+
* [price$, quantity$],
|
|
58
|
+
* (price, quantity) => price * quantity,
|
|
59
|
+
* );
|
|
60
|
+
*
|
|
61
|
+
* const totalHistory: number[] = [];
|
|
62
|
+
*
|
|
63
|
+
* total$.subscribe((total) => {
|
|
64
|
+
* totalHistory.push(total);
|
|
65
|
+
* });
|
|
66
|
+
*
|
|
67
|
+
* price$.next(100); // nothing yet: quantity$ has no value
|
|
68
|
+
*
|
|
69
|
+
* quantity$.next(2); // 200
|
|
70
|
+
*
|
|
71
|
+
* price$.next(120); // 240
|
|
72
|
+
*
|
|
73
|
+
* quantity$.next(3); // 360
|
|
74
|
+
*
|
|
75
|
+
* assert.deepStrictEqual(totalHistory, [200, 240, 360]);
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export const derive = <const OS extends NonEmptyTuple<Observable<unknown>>, B>(
|
|
79
|
+
parents: OS,
|
|
80
|
+
project: (...values: Unwrap<OS>) => B,
|
|
81
|
+
options?: Readonly<{ equals?: (x: B, y: B) => boolean }>,
|
|
82
|
+
): DeriveObservableRefined<B, OS> =>
|
|
83
|
+
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
84
|
+
new DeriveObservableClass(
|
|
85
|
+
parents,
|
|
86
|
+
// `project` is called with the values of `parents`, position by
|
|
87
|
+
// position, which the checker cannot follow through an index.
|
|
88
|
+
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
89
|
+
project as (...values: readonly unknown[]) => B,
|
|
90
|
+
options?.equals ?? Object.is,
|
|
91
|
+
) as unknown as DeriveObservableRefined<B, OS>;
|
|
92
|
+
|
|
93
|
+
class DeriveObservableClass<const A extends NonEmptyUnknownList, B>
|
|
94
|
+
extends SyncChildObservableClass<B, A>
|
|
95
|
+
implements DeriveObservable<B, A>
|
|
96
|
+
{
|
|
97
|
+
readonly #project: (...values: readonly unknown[]) => B;
|
|
98
|
+
readonly #equals: (x: B, y: B) => boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The parents' values, rewritten by every update. `project` receives them
|
|
102
|
+
* spread, as arguments of its own, so nothing it is given changes later and
|
|
103
|
+
* no array is built per update.
|
|
104
|
+
*/
|
|
105
|
+
// @sumi-expect-error readonly/require-readonly-type
|
|
106
|
+
readonly #mut_values: unknown[];
|
|
107
|
+
|
|
108
|
+
constructor(
|
|
109
|
+
parents: Wrap<A>,
|
|
110
|
+
project: (...values: readonly unknown[]) => B,
|
|
111
|
+
equals: (x: B, y: B) => boolean,
|
|
112
|
+
) {
|
|
113
|
+
const snapshots = Arr.map(parents, (p) => p.getSnapshot());
|
|
114
|
+
|
|
115
|
+
const mut_values = Array.from(snapshots, (s): unknown =>
|
|
116
|
+
Optional.isSome(s) ? s.value : undefined,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
super({
|
|
120
|
+
parents,
|
|
121
|
+
initialValue: Arr.every(snapshots, Optional.isSome)
|
|
122
|
+
? Optional.some(project(...mut_values))
|
|
123
|
+
: Optional.none,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.#project = project;
|
|
127
|
+
|
|
128
|
+
this.#equals = equals;
|
|
129
|
+
|
|
130
|
+
this.#mut_values = mut_values;
|
|
131
|
+
|
|
132
|
+
this.attach();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override tryUpdate(updateToken: UpdateToken): void {
|
|
136
|
+
const mut_values = this.#mut_values;
|
|
137
|
+
|
|
138
|
+
let mut_updated = false;
|
|
139
|
+
|
|
140
|
+
let mut_index = 0;
|
|
141
|
+
|
|
142
|
+
for (const parent of this.parents) {
|
|
143
|
+
const snapshot = parent.getSnapshot();
|
|
144
|
+
|
|
145
|
+
if (Optional.isNone(snapshot)) {
|
|
146
|
+
return; // a parent has no value yet
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
mut_values[mut_index] = snapshot.value;
|
|
150
|
+
|
|
151
|
+
mut_index += 1;
|
|
152
|
+
|
|
153
|
+
if (parent.updateToken === updateToken) {
|
|
154
|
+
mut_updated = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!mut_updated) {
|
|
159
|
+
return; // all parents are skipped
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const next = this.#project(...mut_values);
|
|
163
|
+
|
|
164
|
+
const held = this.getSnapshot();
|
|
165
|
+
|
|
166
|
+
if (Optional.isSome(held) && this.#equals(held.value, next)) {
|
|
167
|
+
return; // the result did not change
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.setNext(next, updateToken);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Optional, expectType } from 'ts-data-forge';
|
|
2
|
+
import { source } from '../create/index.mjs';
|
|
3
|
+
import { map, withInitialValue } from '../operators/index.mjs';
|
|
4
|
+
import type {
|
|
5
|
+
InitializedObservable,
|
|
6
|
+
InitializedSyncChildObservable,
|
|
7
|
+
Observable,
|
|
8
|
+
SyncChildObservable,
|
|
9
|
+
} from '../types/index.mjs';
|
|
10
|
+
import { derive } from './derive.mjs';
|
|
11
|
+
|
|
12
|
+
describe(derive, () => {
|
|
13
|
+
test('emits the projection of the latest values, once every parent has one', () => {
|
|
14
|
+
const a$ = source<number>();
|
|
15
|
+
|
|
16
|
+
const b$ = source<string>();
|
|
17
|
+
|
|
18
|
+
const label$ = derive([a$, b$], (a, b) => `${b}${a.toString()}`);
|
|
19
|
+
|
|
20
|
+
const mut_history: string[] = [];
|
|
21
|
+
|
|
22
|
+
label$.subscribe((v) => {
|
|
23
|
+
mut_history.push(v);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
a$.next(1);
|
|
27
|
+
|
|
28
|
+
assert.deepStrictEqual(mut_history, []);
|
|
29
|
+
|
|
30
|
+
b$.next('x');
|
|
31
|
+
|
|
32
|
+
a$.next(2);
|
|
33
|
+
|
|
34
|
+
b$.next('y');
|
|
35
|
+
|
|
36
|
+
assert.deepStrictEqual(mut_history, ['x1', 'x2', 'y2']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('is one observable: a diamond updates it once per write', () => {
|
|
40
|
+
const counter$ = source<number>(0);
|
|
41
|
+
|
|
42
|
+
const doubled$ = counter$.pipe(map((x) => x * 2));
|
|
43
|
+
|
|
44
|
+
const tripled$ = counter$.pipe(map((x) => x * 3));
|
|
45
|
+
|
|
46
|
+
let mut_calls = 0;
|
|
47
|
+
|
|
48
|
+
const sum$ = derive([doubled$, tripled$], (d, t) => {
|
|
49
|
+
mut_calls += 1;
|
|
50
|
+
|
|
51
|
+
return d + t;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const mut_history: number[] = [];
|
|
55
|
+
|
|
56
|
+
sum$.subscribe((v) => {
|
|
57
|
+
mut_history.push(v);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
counter$.next(1);
|
|
61
|
+
|
|
62
|
+
counter$.next(2);
|
|
63
|
+
|
|
64
|
+
assert.deepStrictEqual(mut_history, [0, 5, 10]);
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(mut_calls, 3);
|
|
67
|
+
|
|
68
|
+
assert.deepStrictEqual(sum$.parents, [doubled$, tripled$]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('computes the initial value when every parent has one', () => {
|
|
72
|
+
const a$ = source<number>(1);
|
|
73
|
+
|
|
74
|
+
const b$ = source<number>(2);
|
|
75
|
+
|
|
76
|
+
const sum$ = derive([a$, b$], (a, b) => a + b);
|
|
77
|
+
|
|
78
|
+
assert.deepStrictEqual(sum$.getSnapshot(), Optional.some(3));
|
|
79
|
+
|
|
80
|
+
const c$ = source<number>();
|
|
81
|
+
|
|
82
|
+
const partial$ = derive([a$, c$], (a, c) => a + c);
|
|
83
|
+
|
|
84
|
+
assert.isTrue(Optional.isNone(partial$.getSnapshot()));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a result equal by Object.is to the one held is not emitted', () => {
|
|
88
|
+
const a$ = source<number>(1);
|
|
89
|
+
|
|
90
|
+
const b$ = source<number>(2);
|
|
91
|
+
|
|
92
|
+
const parity$ = derive([a$, b$], (a, b) => (a + b) % 2);
|
|
93
|
+
|
|
94
|
+
const mut_history: number[] = [];
|
|
95
|
+
|
|
96
|
+
parity$.subscribe((v) => {
|
|
97
|
+
mut_history.push(v);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
a$.next(3);
|
|
101
|
+
|
|
102
|
+
b$.next(4);
|
|
103
|
+
|
|
104
|
+
a$.next(4);
|
|
105
|
+
|
|
106
|
+
assert.deepStrictEqual(mut_history, [1, 0]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('`equals` replaces Object.is', () => {
|
|
110
|
+
const a$ = source<number>(1);
|
|
111
|
+
|
|
112
|
+
const b$ = source<number>(2);
|
|
113
|
+
|
|
114
|
+
const pair$ = derive([a$, b$], (a, b) => ({ total: a + b }), {
|
|
115
|
+
equals: (x, y) => x.total === y.total,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const mut_history: number[] = [];
|
|
119
|
+
|
|
120
|
+
pair$.subscribe(({ total }) => {
|
|
121
|
+
mut_history.push(total);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
a$.next(2);
|
|
125
|
+
|
|
126
|
+
// A new object with the same total: equal by `equals`, not by Object.is.
|
|
127
|
+
a$.next(2);
|
|
128
|
+
|
|
129
|
+
b$.next(1);
|
|
130
|
+
|
|
131
|
+
b$.next(5);
|
|
132
|
+
|
|
133
|
+
assert.deepStrictEqual(mut_history, [3, 4, 3, 7]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a write from one parent does not reuse a value another update left', () => {
|
|
137
|
+
const a$ = source<number>(1);
|
|
138
|
+
|
|
139
|
+
const b$ = source<number>(10);
|
|
140
|
+
|
|
141
|
+
const xs$ = derive([a$, b$], (...values) => values);
|
|
142
|
+
|
|
143
|
+
const mut_history: (readonly number[])[] = [];
|
|
144
|
+
|
|
145
|
+
xs$.subscribe((v) => {
|
|
146
|
+
mut_history.push(v);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
a$.next(2);
|
|
150
|
+
|
|
151
|
+
b$.next(20);
|
|
152
|
+
|
|
153
|
+
assert.deepStrictEqual(mut_history, [
|
|
154
|
+
[1, 10],
|
|
155
|
+
[2, 10],
|
|
156
|
+
[2, 20],
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
// Each emission is its own array, so a held value is never changed by a
|
|
160
|
+
// later update.
|
|
161
|
+
assert.notStrictEqual(mut_history[0], mut_history[1]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('types', () => {
|
|
165
|
+
const s1: Observable<1> = source<1>();
|
|
166
|
+
|
|
167
|
+
const s2: InitializedObservable<2> = source<2>().pipe(withInitialValue(2));
|
|
168
|
+
|
|
169
|
+
const p = derive([s1, s2], (a, b) => {
|
|
170
|
+
expectType<typeof a, 1>('=');
|
|
171
|
+
|
|
172
|
+
expectType<typeof b, 2>('=');
|
|
173
|
+
|
|
174
|
+
return 'x' as const;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expectType<typeof p, SyncChildObservable<'x', readonly [1, 2]>>('=');
|
|
178
|
+
|
|
179
|
+
const s3: InitializedObservable<3> = source<3>().pipe(withInitialValue(3));
|
|
180
|
+
|
|
181
|
+
const _q = derive([s2, s3], (a, b) => a + b);
|
|
182
|
+
|
|
183
|
+
expectType<
|
|
184
|
+
typeof _q,
|
|
185
|
+
InitializedSyncChildObservable<number, readonly [2, 3]>
|
|
186
|
+
>('=');
|
|
187
|
+
|
|
188
|
+
expect(p).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/core/types/id.mts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Brand, UnknownRecord } from 'ts-type-forge';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Identifies one update. Opaque: an object issued per update, which is also
|
|
5
|
-
* that update's queue
|
|
5
|
+
* that update's queue — except for a write nothing observes, which opens no
|
|
6
|
+
* update and is issued a token with no queue behind it.
|
|
6
7
|
*/
|
|
7
8
|
export type UpdateToken = Brand<UnknownRecord, 'update-token'>;
|
|
@@ -109,6 +109,14 @@ namespace SynStateInternals {
|
|
|
109
109
|
? InitializedCombineObservableImpl<Cast<Unwrap<OS>>>
|
|
110
110
|
: CombineObservableImpl<Unwrap<OS>>;
|
|
111
111
|
|
|
112
|
+
export type DeriveObservableRefinedImpl<
|
|
113
|
+
B,
|
|
114
|
+
OS extends NonEmptyTuple<Observable<unknown>>,
|
|
115
|
+
> =
|
|
116
|
+
EveryInitialized<OS> extends true
|
|
117
|
+
? InitializedSyncChildObservable<B, Cast<Unwrap<OS>>>
|
|
118
|
+
: SyncChildObservable<B, Unwrap<OS>>;
|
|
119
|
+
|
|
112
120
|
type InitializedZipObservableImpl<A extends NonEmptyUnknownList> =
|
|
113
121
|
InitializedSyncChildObservable<A, A>;
|
|
114
122
|
|
|
@@ -189,6 +197,16 @@ export type CombineObservableRefined<
|
|
|
189
197
|
OS extends NonEmptyTuple<Observable<unknown>>,
|
|
190
198
|
> = SynStateInternals.CombineObservableRefinedImpl<OS>;
|
|
191
199
|
|
|
200
|
+
export type DeriveObservable<
|
|
201
|
+
B,
|
|
202
|
+
A extends NonEmptyUnknownList,
|
|
203
|
+
> = SyncChildObservable<B, A>;
|
|
204
|
+
|
|
205
|
+
export type DeriveObservableRefined<
|
|
206
|
+
B,
|
|
207
|
+
OS extends NonEmptyTuple<Observable<unknown>>,
|
|
208
|
+
> = SynStateInternals.DeriveObservableRefinedImpl<B, OS>;
|
|
209
|
+
|
|
192
210
|
export type ZipObservable<A extends NonEmptyUnknownList> =
|
|
193
211
|
SynStateInternals.ZipObservableImpl<A>;
|
|
194
212
|
|
|
@@ -44,7 +44,8 @@ type CreateObservableType<A, Kind extends ObservableKind> = Readonly<{
|
|
|
44
44
|
* The token of the update in which this observable last emitted. A child's
|
|
45
45
|
* `tryUpdate` receives the token of the update now propagating; a parent
|
|
46
46
|
* whose token differs did not emit in it. The token is opaque: compare it,
|
|
47
|
-
* store it, or pass
|
|
47
|
+
* store it, or pass the one `tryUpdate` received on to `setNext`. A write
|
|
48
|
+
* nothing observes opens no update, and its token has no queue behind it.
|
|
48
49
|
*/
|
|
49
50
|
updateToken: UpdateToken;
|
|
50
51
|
|
|
@@ -8,3 +8,13 @@ import type { UpdateToken } from '../types/index.mjs';
|
|
|
8
8
|
export const notUpdatedYet: UpdateToken =
|
|
9
9
|
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
10
10
|
Object.freeze({}) as UpdateToken;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Issues the token of a write that opens no update, because nothing observes
|
|
14
|
+
* the observable written to (`setNextIfUnobserved`). Nothing is ever queued
|
|
15
|
+
* with it, so it is a token and not a queue, unlike the tokens
|
|
16
|
+
* `openUpdate` issues.
|
|
17
|
+
*/
|
|
18
|
+
export const issueUnqueuedToken = (): UpdateToken =>
|
|
19
|
+
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
20
|
+
({}) as UpdateToken;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReplaceEqualDeepOptions } from 'ts-data-forge';
|
|
2
2
|
import type { Reducer } from 'ts-type-forge';
|
|
3
|
-
import {
|
|
3
|
+
import type { InitializedObservable } from '../core/index.mjs';
|
|
4
|
+
import { createStateSource } from './state-source.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Creates a reducer-based state management container following the Redux pattern.
|
|
@@ -91,33 +92,17 @@ export const createReducer = <S, A>(
|
|
|
91
92
|
initialState: S;
|
|
92
93
|
}>,
|
|
93
94
|
] => {
|
|
94
|
-
const state =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const keepEqualParts = options?.keepEqualParts ?? false;
|
|
99
|
-
|
|
100
|
-
const keepOptions =
|
|
101
|
-
typeof keepEqualParts === 'boolean' ? undefined : keepEqualParts;
|
|
95
|
+
const { state, getSnapshot, commit } = createStateSource(
|
|
96
|
+
initialState,
|
|
97
|
+
options,
|
|
98
|
+
);
|
|
102
99
|
|
|
103
100
|
const dispatch = (action: A): S => {
|
|
104
|
-
const currentState =
|
|
101
|
+
const currentState = getSnapshot();
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const nextState =
|
|
109
|
-
keepEqualParts === false
|
|
110
|
-
? reduced
|
|
111
|
-
: replaceEqualDeep(currentState, reduced, keepOptions);
|
|
112
|
-
|
|
113
|
-
// Once completed, `next` keeps the state held and returns it.
|
|
114
|
-
return equals(currentState, nextState)
|
|
115
|
-
? currentState
|
|
116
|
-
: state.next(nextState);
|
|
103
|
+
return commit(currentState, reducer(currentState, action));
|
|
117
104
|
};
|
|
118
105
|
|
|
119
|
-
const getSnapshot = (): S => state.getSnapshot().value;
|
|
120
|
-
|
|
121
106
|
return [state, dispatch, { getSnapshot, initialState }] as const;
|
|
122
107
|
};
|
|
123
108
|
|
|
@@ -1,26 +1,6 @@
|
|
|
1
1
|
import type { InitializedObservable } from '../core/index.mjs';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type Action<S> = Readonly<
|
|
5
|
-
| {
|
|
6
|
-
type: 'set';
|
|
7
|
-
nextState: S;
|
|
8
|
-
}
|
|
9
|
-
| {
|
|
10
|
-
type: 'update';
|
|
11
|
-
updateFn: (a: S) => S;
|
|
12
|
-
}
|
|
13
|
-
>;
|
|
14
|
-
|
|
15
|
-
const reducer = <S,>(state: S, action: Action<S>): S => {
|
|
16
|
-
switch (action.type) {
|
|
17
|
-
case 'set':
|
|
18
|
-
return action.nextState;
|
|
19
|
-
|
|
20
|
-
case 'update':
|
|
21
|
-
return action.updateFn(state);
|
|
22
|
-
}
|
|
23
|
-
};
|
|
2
|
+
import type { StateOptions } from './create-reducer.mjs';
|
|
3
|
+
import { createStateSource } from './state-source.mjs';
|
|
24
4
|
|
|
25
5
|
/**
|
|
26
6
|
* Creates a reactive state container with getter and setter methods.
|
|
@@ -161,19 +141,22 @@ export const createState = <S,>(
|
|
|
161
141
|
initialState: S;
|
|
162
142
|
}>,
|
|
163
143
|
] => {
|
|
164
|
-
const
|
|
165
|
-
reducer,
|
|
144
|
+
const { state, getSnapshot, commit } = createStateSource(
|
|
166
145
|
initialState,
|
|
167
146
|
options,
|
|
168
147
|
);
|
|
169
148
|
|
|
170
|
-
|
|
171
|
-
|
|
149
|
+
// Each compares with the state the write started from, as `dispatch`
|
|
150
|
+
// does, and builds nothing on the way.
|
|
151
|
+
const updateState = (updateFn: (prev: S) => S): S => {
|
|
152
|
+
const currentState = getSnapshot();
|
|
153
|
+
|
|
154
|
+
return commit(currentState, updateFn(currentState));
|
|
155
|
+
};
|
|
172
156
|
|
|
173
|
-
const setState = (nextState: S): S =>
|
|
157
|
+
const setState = (nextState: S): S => commit(getSnapshot(), nextState);
|
|
174
158
|
|
|
175
|
-
const resetState = (): S =>
|
|
176
|
-
dispatch({ type: 'set', nextState: initialState });
|
|
159
|
+
const resetState = (): S => commit(getSnapshot(), initialState);
|
|
177
160
|
|
|
178
161
|
return [
|
|
179
162
|
state,
|
|
@@ -77,6 +77,24 @@ describe(createState, () => {
|
|
|
77
77
|
assert.deepStrictEqual(seen, [held]);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('updateState compares the result with the state updateFn was given', () => {
|
|
81
|
+
const [state, setState, { updateState }] = createState(0);
|
|
82
|
+
|
|
83
|
+
const seen = record(state);
|
|
84
|
+
|
|
85
|
+
// updateFn writes the state itself, then returns what it was given:
|
|
86
|
+
// equal to that, so nothing more is written and 5 stays.
|
|
87
|
+
updateState((prev) => {
|
|
88
|
+
setState(5);
|
|
89
|
+
|
|
90
|
+
return prev;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.deepStrictEqual(seen, [0, 5]);
|
|
94
|
+
|
|
95
|
+
assert.strictEqual(state.getSnapshot().value, 5);
|
|
96
|
+
});
|
|
97
|
+
|
|
80
98
|
describe('keepEqualParts', () => {
|
|
81
99
|
type Report = Readonly<{
|
|
82
100
|
summary: Readonly<{ open: number }>;
|