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
|
@@ -1,137 +1,32 @@
|
|
|
1
1
|
// @sumi-expect-error-file classes/no-class
|
|
2
2
|
// @sumi-expect-error-file banned-syntax/no-this
|
|
3
3
|
// @sumi-expect-error-file functions/prefer-arrow-function
|
|
4
|
-
|
|
5
|
-
import type { Optional } from 'ts-data-forge';
|
|
6
|
-
import type { MutableSet } from 'ts-type-forge';
|
|
7
|
-
import type { ManagerObservableKind, UpdateToken } from '../types/index.mjs';
|
|
8
|
-
import { issueUpdateToken } from '../utils/index.mjs';
|
|
4
|
+
import type { ManagerObservableKind } from '../types/index.mjs';
|
|
9
5
|
import { ObservableBaseClass } from './observable-base-class.mjs';
|
|
10
|
-
import
|
|
11
|
-
|
|
6
|
+
import {
|
|
7
|
+
beginPropagation,
|
|
8
|
+
closeUpdate,
|
|
9
|
+
drainUpdate,
|
|
10
|
+
endPropagation,
|
|
11
|
+
openUpdate,
|
|
12
|
+
} from './propagation-state.mjs';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The base of the observables that start updates of their own: a root, and
|
|
15
16
|
* an async child (`debounce`, `switchMap`), which emits later than its
|
|
16
|
-
* parents do.
|
|
17
|
-
*
|
|
18
|
-
* parents' values of that
|
|
17
|
+
* parents do. Its update visits, shallowest first, the nodes its value
|
|
18
|
+
* reaches: each node that emits puts its children in the update's queue
|
|
19
|
+
* (`propagation-state.mts`), so that each sees its parents' values of that
|
|
20
|
+
* update (no glitch) and a node no value reached is not visited.
|
|
19
21
|
*/
|
|
20
22
|
export class ManagerObservableClass<
|
|
21
23
|
A,
|
|
22
24
|
Kind extends ManagerObservableKind,
|
|
23
25
|
Depth extends number,
|
|
24
26
|
> extends ObservableBaseClass<A, Kind, Depth> {
|
|
25
|
-
/**
|
|
26
|
-
* The descendants, by depth: the set at index `i` holds those at
|
|
27
|
-
* `depth + 1 + i`, in the order they registered. Depths below a live node
|
|
28
|
-
* never skip one (every live node has a live parent one level up), so the
|
|
29
|
-
* array has few holes; a hole is `undefined`, never a gap left for V8 to
|
|
30
|
-
* treat as a holey array.
|
|
31
|
-
*
|
|
32
|
-
* An update walks this array and its sets in place. Adding or removing a
|
|
33
|
-
* node is O(1) and there is no flat order to rebuild; only the first node
|
|
34
|
-
* of a depth replaces the array with a longer copy. A node deleted during
|
|
35
|
-
* the walk is skipped by the set's iterator; a node registered during it is
|
|
36
|
-
* also put in `#mut_joinedDuringUpdate` and skipped, so an update visits the
|
|
37
|
-
* nodes that existed when it began.
|
|
38
|
-
*/
|
|
39
|
-
#mut_propagationOrder: readonly (MutableSet<ChildNode> | undefined)[];
|
|
40
|
-
|
|
41
|
-
/** Whether this node is propagating an update of its own. */
|
|
42
|
-
#mut_isPropagating: boolean;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The descendants that registered while this node propagated the update
|
|
46
|
-
* under way, which that update does not visit; made when the first one
|
|
47
|
-
* arrives. Kept on the manager rather than in a module, so that it also
|
|
48
|
-
* holds for a node another copy of synstate registered.
|
|
49
|
-
*/
|
|
50
|
-
#mut_joinedDuringUpdate: MutableSet<ChildNode> | undefined;
|
|
51
|
-
|
|
52
|
-
/** The managers this node's descendants register to: itself. */
|
|
53
|
-
readonly managers: readonly ManagerNode[];
|
|
54
|
-
|
|
55
|
-
constructor(
|
|
56
|
-
config: Readonly<{
|
|
57
|
-
kind: Kind;
|
|
58
|
-
depth: Depth;
|
|
59
|
-
initialValue: Optional<A>;
|
|
60
|
-
}>,
|
|
61
|
-
) {
|
|
62
|
-
super(config);
|
|
63
|
-
|
|
64
|
-
this.managers = [this];
|
|
65
|
-
|
|
66
|
-
this.#mut_propagationOrder = [];
|
|
67
|
-
|
|
68
|
-
this.#mut_isPropagating = false;
|
|
69
|
-
|
|
70
|
-
this.#mut_joinedDuringUpdate = undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Runs `tryUpdate` on the descendants, shallowest first, skipping those
|
|
75
|
-
* registered after this update began.
|
|
76
|
-
*/
|
|
77
|
-
#propagate(updateToken: UpdateToken): void {
|
|
78
|
-
for (const bucket of this.#mut_propagationOrder) {
|
|
79
|
-
if (bucket !== undefined) {
|
|
80
|
-
for (const c of bucket) {
|
|
81
|
-
// Read for each node, since the set is made when the first joins.
|
|
82
|
-
if (this.#mut_joinedDuringUpdate?.has(c) !== true) {
|
|
83
|
-
c.tryUpdate(updateToken);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
addDescendant(child: ChildNode): void {
|
|
91
|
-
const descendants = this.#mut_propagationOrder;
|
|
92
|
-
|
|
93
|
-
const index = child.depth - this.depth - 1;
|
|
94
|
-
|
|
95
|
-
const mut_bucket = descendants[index];
|
|
96
|
-
|
|
97
|
-
if (mut_bucket === undefined) {
|
|
98
|
-
// The first node of its depth: rare, so the array is copied rather than
|
|
99
|
-
// written, which also leaves a walk already under way on the old one.
|
|
100
|
-
this.#mut_propagationOrder = Array.from(
|
|
101
|
-
{ length: Math.max(descendants.length, index + 1) },
|
|
102
|
-
(_, i) => (i === index ? new Set([child]) : descendants[i]),
|
|
103
|
-
);
|
|
104
|
-
} else {
|
|
105
|
-
mut_bucket.add(child);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (!this.#mut_isPropagating) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
this.#mut_joinedDuringUpdate ??= new Set();
|
|
113
|
-
|
|
114
|
-
this.#mut_joinedDuringUpdate.add(child);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
deleteDescendant(child: ChildNode): void {
|
|
118
|
-
// An emptied set stays, to be reused by the next node of its depth.
|
|
119
|
-
this.#mut_propagationOrder[child.depth - this.depth - 1]?.delete(child);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
27
|
/** Emits `nextValue` and propagates it to the descendants. */
|
|
123
28
|
protected startUpdate(nextValue: A): void {
|
|
124
|
-
const updateToken =
|
|
125
|
-
|
|
126
|
-
// A write to this same manager from inside its own update starts an inner
|
|
127
|
-
// one; what joins during it joined during the outer one too.
|
|
128
|
-
const wasPropagating = this.#mut_isPropagating;
|
|
129
|
-
|
|
130
|
-
const joinedBefore = this.#mut_joinedDuringUpdate;
|
|
131
|
-
|
|
132
|
-
this.#mut_isPropagating = true;
|
|
133
|
-
|
|
134
|
-
this.#mut_joinedDuringUpdate = undefined;
|
|
29
|
+
const updateToken = openUpdate(this.depth);
|
|
135
30
|
|
|
136
31
|
beginPropagation(updateToken);
|
|
137
32
|
|
|
@@ -142,23 +37,9 @@ export class ManagerObservableClass<
|
|
|
142
37
|
try {
|
|
143
38
|
this.setNext(nextValue, updateToken);
|
|
144
39
|
|
|
145
|
-
|
|
40
|
+
drainUpdate(updateToken);
|
|
146
41
|
} finally {
|
|
147
|
-
|
|
148
|
-
const joinedNow = this.#mut_joinedDuringUpdate as
|
|
149
|
-
MutableSet<ChildNode> | undefined;
|
|
150
|
-
|
|
151
|
-
this.#mut_isPropagating = wasPropagating;
|
|
152
|
-
|
|
153
|
-
this.#mut_joinedDuringUpdate = joinedBefore;
|
|
154
|
-
|
|
155
|
-
if (wasPropagating && joinedNow !== undefined) {
|
|
156
|
-
this.#mut_joinedDuringUpdate ??= new Set();
|
|
157
|
-
|
|
158
|
-
for (const c of joinedNow) {
|
|
159
|
-
this.#mut_joinedDuringUpdate.add(c);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
42
|
+
closeUpdate(updateToken);
|
|
162
43
|
|
|
163
44
|
endPropagation();
|
|
164
45
|
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type ScopeInternals,
|
|
30
30
|
} from '../utils/index.mjs';
|
|
31
31
|
import type { ChildNode } from './observable-node.mjs';
|
|
32
|
+
import { enqueueChildren } from './propagation-state.mjs';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* The base of every observable, and the reason an observable is a class.
|
|
@@ -64,6 +65,7 @@ export class ObservableBaseClass<
|
|
|
64
65
|
#mut_children: MutableSet<ChildNode> | undefined;
|
|
65
66
|
#mut_subscribers: MutableSet<Subscriber<A>> | undefined;
|
|
66
67
|
#mut_scope: ScopeInternals | undefined;
|
|
68
|
+
#mut_queuedFor: UpdateToken | undefined;
|
|
67
69
|
readonly kind: Kind;
|
|
68
70
|
readonly depth: Depth;
|
|
69
71
|
declare readonly [observableBrand]: true;
|
|
@@ -87,6 +89,8 @@ export class ObservableBaseClass<
|
|
|
87
89
|
|
|
88
90
|
this.#mut_updateToken = notUpdatedYet;
|
|
89
91
|
|
|
92
|
+
this.#mut_queuedFor = undefined;
|
|
93
|
+
|
|
90
94
|
this.#mut_children = undefined;
|
|
91
95
|
|
|
92
96
|
this.#mut_subscribers = undefined;
|
|
@@ -130,6 +134,14 @@ export class ObservableBaseClass<
|
|
|
130
134
|
return false;
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
get queuedFor(): UpdateToken | undefined {
|
|
138
|
+
return this.#mut_queuedFor;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setQueuedFor(updateToken: UpdateToken): void {
|
|
142
|
+
this.#mut_queuedFor = updateToken;
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
addChild(child: ChildNode): void {
|
|
134
146
|
// A set, not an array: a completed child removes itself, and that has to
|
|
135
147
|
// be O(1) for a UI that creates and completes derived nodes on every mount.
|
|
@@ -144,12 +156,21 @@ export class ObservableBaseClass<
|
|
|
144
156
|
this.#mut_children?.delete(child);
|
|
145
157
|
}
|
|
146
158
|
|
|
147
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Stores `nextValue` as the current value, queues the children for the
|
|
161
|
+
* update `updateToken`, and notifies subscribers. The children are queued
|
|
162
|
+
* first, so that a child a subscriber creates, which starts from this
|
|
163
|
+
* value, is not visited for it again.
|
|
164
|
+
*/
|
|
148
165
|
protected setNext(nextValue: A, updateToken: UpdateToken): void {
|
|
149
166
|
this.#mut_updateToken = updateToken;
|
|
150
167
|
|
|
151
168
|
this.#mut_snapshot = Optional.some(nextValue);
|
|
152
169
|
|
|
170
|
+
if (this.#mut_children !== undefined) {
|
|
171
|
+
enqueueChildren(this.#mut_children, updateToken);
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
if (this.#mut_subscribers !== undefined) {
|
|
154
175
|
for (const s of this.#mut_subscribers) {
|
|
155
176
|
s.onNext(nextValue);
|
|
@@ -248,7 +269,7 @@ export class ObservableBaseClass<
|
|
|
248
269
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
249
270
|
protected teardown(): void {}
|
|
250
271
|
|
|
251
|
-
/** Takes a completed child out of its parents'
|
|
272
|
+
/** Takes a completed child out of its parents' lists. */
|
|
252
273
|
// eslint-disable-next-line @typescript-eslint/class-methods-use-this
|
|
253
274
|
protected detach(): void {}
|
|
254
275
|
|
|
@@ -20,11 +20,12 @@ type NodeInternals = Readonly<{
|
|
|
20
20
|
deleteChild: (child: ChildNode) => void;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
* has one. Cached so that creating a child does not walk its ancestors.
|
|
23
|
+
* The last update that queued this node (`propagation-state.mts`). Left in
|
|
24
|
+
* place after the visit: the next update has another token.
|
|
26
25
|
*/
|
|
27
|
-
|
|
26
|
+
queuedFor: UpdateToken | undefined;
|
|
27
|
+
|
|
28
|
+
setQueuedFor: (updateToken: UpdateToken) => void;
|
|
28
29
|
}>;
|
|
29
30
|
|
|
30
31
|
/** An observable, as the other nodes of the graph see it. */
|
|
@@ -36,13 +37,6 @@ export type ChildNode = NodeInternals &
|
|
|
36
37
|
parents: readonly Observable<unknown>[];
|
|
37
38
|
}>;
|
|
38
39
|
|
|
39
|
-
export type ManagerNode = NodeInternals &
|
|
40
|
-
ObservableBase<unknown> &
|
|
41
|
-
Readonly<{
|
|
42
|
-
addDescendant: (child: ChildNode) => void;
|
|
43
|
-
deleteDescendant: (child: ChildNode) => void;
|
|
44
|
-
}>;
|
|
45
|
-
|
|
46
40
|
/**
|
|
47
41
|
* The one place a public observable is seen as the node it is. Every
|
|
48
42
|
* observable is built by a synstate class, and the public types carry a
|
|
@@ -1,8 +1,75 @@
|
|
|
1
|
+
import type { FixedLengthTuple } from 'ts-type-forge';
|
|
1
2
|
import { createState } from '../../utils/index.mjs';
|
|
2
3
|
import { combine } from '../combine/index.mjs';
|
|
3
4
|
import { map } from '../operators/index.mjs';
|
|
4
5
|
|
|
5
6
|
describe('the nodes an update visits', () => {
|
|
7
|
+
test('a node an update nested in another reaches is still visited once by the outer one', () => {
|
|
8
|
+
const [source$, setSource] = createState(0);
|
|
9
|
+
|
|
10
|
+
const [other$, setOther] = createState(0);
|
|
11
|
+
|
|
12
|
+
const a$ = source$.pipe(map((x) => x));
|
|
13
|
+
|
|
14
|
+
const b$ = source$.pipe(map((x) => x));
|
|
15
|
+
|
|
16
|
+
const c$ = combine([a$, b$, other$]);
|
|
17
|
+
|
|
18
|
+
const mut_received: FixedLengthTuple<3, number>[] = [];
|
|
19
|
+
|
|
20
|
+
c$.subscribe((v) => {
|
|
21
|
+
mut_received.push(v);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Runs after `a$` has queued `c$` for the outer update, and before `b$`
|
|
25
|
+
// queues it again.
|
|
26
|
+
b$.subscribe((x) => {
|
|
27
|
+
if (x === 1) {
|
|
28
|
+
setOther(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
setSource(1);
|
|
33
|
+
|
|
34
|
+
assert.deepStrictEqual(mut_received, [
|
|
35
|
+
[0, 0, 0],
|
|
36
|
+
// the nested update
|
|
37
|
+
[1, 1, 1],
|
|
38
|
+
// the outer update
|
|
39
|
+
[1, 1, 1],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a node created during an update is visited by it if a parent emits after', () => {
|
|
44
|
+
const [count$, setCount] = createState(0);
|
|
45
|
+
|
|
46
|
+
const early$ = count$.pipe(map((x) => x));
|
|
47
|
+
|
|
48
|
+
// Depth 2: emits after the subscribers of `early$` have run.
|
|
49
|
+
const late$ = count$.pipe(map((x) => x)).pipe(map((x) => x));
|
|
50
|
+
|
|
51
|
+
const mut_received: number[] = [];
|
|
52
|
+
|
|
53
|
+
let mut_created = false;
|
|
54
|
+
|
|
55
|
+
early$.subscribe((x) => {
|
|
56
|
+
if (x !== 1 || mut_created) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
mut_created = true;
|
|
61
|
+
|
|
62
|
+
// Starts from `late$`'s value before this update.
|
|
63
|
+
late$.pipe(map((y) => y * 10)).subscribe((v) => {
|
|
64
|
+
mut_received.push(v);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
setCount(1);
|
|
69
|
+
|
|
70
|
+
assert.deepStrictEqual(mut_received, [0, 10]);
|
|
71
|
+
});
|
|
72
|
+
|
|
6
73
|
test('a node created while an update propagates is not visited by it', () => {
|
|
7
74
|
const [count$, setCount] = createState(0);
|
|
8
75
|
|
|
@@ -39,6 +106,46 @@ describe('the nodes an update visits', () => {
|
|
|
39
106
|
expect(mut_calls).toBe(2);
|
|
40
107
|
});
|
|
41
108
|
|
|
109
|
+
test('a node of a new depth created while an update propagates is not visited by it', () => {
|
|
110
|
+
const [count$, setCount] = createState(0);
|
|
111
|
+
|
|
112
|
+
let mut_calls = 0;
|
|
113
|
+
|
|
114
|
+
const countCall = (x: number): number => {
|
|
115
|
+
mut_calls += 1;
|
|
116
|
+
|
|
117
|
+
return x;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let mut_created = false;
|
|
121
|
+
|
|
122
|
+
count$.subscribe((x) => {
|
|
123
|
+
if (x !== 1 || mut_created) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
mut_created = true;
|
|
128
|
+
|
|
129
|
+
// Depth 3: deeper than anything `count$` had, so the manager's array
|
|
130
|
+
// grows while it is being walked.
|
|
131
|
+
count$
|
|
132
|
+
.pipe(map((y) => y))
|
|
133
|
+
.pipe(map((y) => y))
|
|
134
|
+
.pipe(map(countCall))
|
|
135
|
+
.subscribe(() => {});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
count$.pipe(map((x) => x)).subscribe(() => {});
|
|
139
|
+
|
|
140
|
+
setCount(1);
|
|
141
|
+
|
|
142
|
+
expect(mut_calls).toBe(1);
|
|
143
|
+
|
|
144
|
+
setCount(2);
|
|
145
|
+
|
|
146
|
+
expect(mut_calls).toBe(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
42
149
|
test('a node completed while an update propagates is not visited by it', () => {
|
|
43
150
|
const [count$, setCount] = createState(0);
|
|
44
151
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// @sumi-expect-error-file classes/no-class
|
|
2
|
+
// @sumi-expect-error-file banned-syntax/no-this
|
|
3
|
+
// @sumi-expect-error-file functions/prefer-arrow-function
|
|
4
|
+
// @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
|
|
1
5
|
/**
|
|
2
6
|
* The state of the update propagating now, shared by every observable.
|
|
3
7
|
*
|
|
@@ -8,7 +12,10 @@
|
|
|
8
12
|
* update takes.
|
|
9
13
|
*/
|
|
10
14
|
import { Arr } from 'ts-data-forge';
|
|
15
|
+
import { panic } from 'ts-std-forge';
|
|
16
|
+
import type { MutableSet } from 'ts-type-forge';
|
|
11
17
|
import type { UpdateToken } from '../types/index.mjs';
|
|
18
|
+
import type { ChildNode } from './observable-node.mjs';
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
21
|
* How many `startUpdate` calls are running, across every observable, and
|
|
@@ -78,3 +85,258 @@ export const endPropagation = (): void => {
|
|
|
78
85
|
mut_afterPropagation.shift()?.();
|
|
79
86
|
}
|
|
80
87
|
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The buckets of this copy's open updates, one set per nesting level: a
|
|
91
|
+
* bucket per depth below the node that started the update, holding the
|
|
92
|
+
* queued nodes and how many of its slots are in use. Kept from update to
|
|
93
|
+
* update, so that an update allocates nothing but its token; emptying a
|
|
94
|
+
* bucket is setting its length to 0.
|
|
95
|
+
*/
|
|
96
|
+
const mut_bucketSets: Bucket[][] = [];
|
|
97
|
+
|
|
98
|
+
/** How many updates of this copy are open, the outermost included. */
|
|
99
|
+
const mut_queueState: { level: number } = { level: 0 };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Opens an update started by a node at `depth`, and returns its token. The
|
|
103
|
+
* token is the update's queue: a node that emits with it puts its children
|
|
104
|
+
* there, whichever copy of synstate the node belongs to, so that an update
|
|
105
|
+
* reaches every node its value reaches, and each once, in ascending depth.
|
|
106
|
+
* Close it with {@link closeUpdate}.
|
|
107
|
+
*/
|
|
108
|
+
export const openUpdate = (depth: number): UpdateToken => {
|
|
109
|
+
if (mut_queueState.level >= maxUpdateNesting) {
|
|
110
|
+
// A programming error, as a circular dependency is: `panic` keeps the
|
|
111
|
+
// `Result` boundaries from turning it into an `Err`. Thrown before this
|
|
112
|
+
// update opens, so only the updates around it close, each in its own
|
|
113
|
+
// `startUpdate`'s `finally`.
|
|
114
|
+
panic(updateLoopMessage);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
mut_queueState.level += 1;
|
|
118
|
+
|
|
119
|
+
return toToken(new UpdateQueue(depth + 1, mut_queueState.level));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How many updates of one copy may be open at once, one inside another. An
|
|
124
|
+
* update opens inside another only when a subscriber or projection writes to
|
|
125
|
+
* a root while the outer one is propagating; a chain of such writes this deep
|
|
126
|
+
* is a loop that would otherwise run until the stack overflows.
|
|
127
|
+
*/
|
|
128
|
+
export const maxUpdateNesting = 100;
|
|
129
|
+
|
|
130
|
+
export const updateLoopMessage =
|
|
131
|
+
`synstate: more than ${maxUpdateNesting.toString()} updates were nested, one started inside another: a subscriber or projection keeps writing to an observable whose update reaches it again. Make the write stop (a condition, or a value equal to the current one), or derive the value with map / combine.` as const;
|
|
132
|
+
|
|
133
|
+
/** Visits every node the update has queued, shallowest first. */
|
|
134
|
+
export const drainUpdate = (updateToken: UpdateToken): void => {
|
|
135
|
+
toQueue(updateToken).drain();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Closes an update, emptying what an exception left in its buckets. */
|
|
139
|
+
export const closeUpdate = (updateToken: UpdateToken): void => {
|
|
140
|
+
toQueue(updateToken).close();
|
|
141
|
+
|
|
142
|
+
mut_queueState.level -= 1;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Puts the children of a node that emitted with `updateToken` into that
|
|
147
|
+
* update's queue. The update may belong to another copy of synstate.
|
|
148
|
+
*/
|
|
149
|
+
export const enqueueChildren = (
|
|
150
|
+
children: ReadonlySet<ChildNode>,
|
|
151
|
+
updateToken: UpdateToken,
|
|
152
|
+
): void => {
|
|
153
|
+
toQueue(updateToken).enqueueChildren(children);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Written in place: a bucket is emptied by its length, not replaced.
|
|
157
|
+
// transformer-ignore-next-line convert-to-readonly
|
|
158
|
+
type Bucket = {
|
|
159
|
+
/** The queued nodes at one depth; the slots from `length` on are unused. */
|
|
160
|
+
// @sumi-expect-error readonly/require-readonly-type
|
|
161
|
+
readonly nodes: (ChildNode | undefined)[];
|
|
162
|
+
/** How many slots of `nodes` are in use. */
|
|
163
|
+
// @sumi-expect-error readonly/require-readonly-type
|
|
164
|
+
length: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The queue of one update, and its token.
|
|
169
|
+
*
|
|
170
|
+
* A queued node is marked with the token of the update that queued it
|
|
171
|
+
* (`queuedFor`), which is how an update queues a node once. The mark is not
|
|
172
|
+
* cleared after the visit: a later update has another token. An update does
|
|
173
|
+
* not overwrite the mark of an update that is still open — one it is nested
|
|
174
|
+
* in, or one of another copy of synstate — and remembers the nodes it
|
|
175
|
+
* queues despite such a mark in a set of its own instead.
|
|
176
|
+
*/
|
|
177
|
+
class UpdateQueue {
|
|
178
|
+
readonly #base: number;
|
|
179
|
+
|
|
180
|
+
/** `buckets[index]` holds the queued nodes at depth `base + index`. */
|
|
181
|
+
// @sumi-expect-error readonly/require-readonly-type
|
|
182
|
+
readonly #mut_buckets: Bucket[];
|
|
183
|
+
|
|
184
|
+
#mut_size: number;
|
|
185
|
+
|
|
186
|
+
#mut_isOpen: boolean;
|
|
187
|
+
|
|
188
|
+
#mut_queuedDespiteMark: MutableSet<ChildNode> | undefined;
|
|
189
|
+
|
|
190
|
+
/** `level` is 1 for the outermost update of this copy. */
|
|
191
|
+
constructor(base: number, level: number) {
|
|
192
|
+
this.#base = base;
|
|
193
|
+
|
|
194
|
+
const mut_buckets = mut_bucketSets[level - 1] ?? [];
|
|
195
|
+
|
|
196
|
+
mut_bucketSets[level - 1] = mut_buckets;
|
|
197
|
+
|
|
198
|
+
this.#mut_buckets = mut_buckets;
|
|
199
|
+
|
|
200
|
+
this.#mut_size = 0;
|
|
201
|
+
|
|
202
|
+
this.#mut_isOpen = true;
|
|
203
|
+
|
|
204
|
+
this.#mut_queuedDespiteMark = undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#push(child: ChildNode): void {
|
|
208
|
+
const index = child.depth - this.#base;
|
|
209
|
+
|
|
210
|
+
const mut_buckets = this.#mut_buckets;
|
|
211
|
+
|
|
212
|
+
while (mut_buckets.length <= index) {
|
|
213
|
+
mut_buckets.push({ nodes: [], length: 0 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (index >= this.#mut_size) {
|
|
217
|
+
this.#mut_size = index + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Defined: the loop above made it.
|
|
221
|
+
// @sumi-expect-error banned-syntax/no-non-null-assertion
|
|
222
|
+
const mut_bucket = mut_buckets[index]!;
|
|
223
|
+
|
|
224
|
+
const { nodes: mut_nodes, length } = mut_bucket;
|
|
225
|
+
|
|
226
|
+
if (length < mut_nodes.length) {
|
|
227
|
+
mut_nodes[length] = child;
|
|
228
|
+
} else {
|
|
229
|
+
mut_nodes.push(child);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
mut_bucket.length = length + 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whether the update is still running; read across copies of synstate. */
|
|
236
|
+
get isOpen(): boolean {
|
|
237
|
+
return this.#mut_isOpen;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
enqueueChildren(children: ReadonlySet<ChildNode>): void {
|
|
241
|
+
const ownMark = toToken(this);
|
|
242
|
+
|
|
243
|
+
for (const child of children) {
|
|
244
|
+
const mark = child.queuedFor;
|
|
245
|
+
|
|
246
|
+
if (mark === ownMark) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (mark !== undefined && toQueue(mark).isOpen) {
|
|
251
|
+
// Another open update has queued `child` and may not have visited
|
|
252
|
+
// it yet: leave its mark alone.
|
|
253
|
+
if (this.#mut_queuedDespiteMark === undefined) {
|
|
254
|
+
this.#mut_queuedDespiteMark = new Set();
|
|
255
|
+
} else if (this.#mut_queuedDespiteMark.has(child)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.#mut_queuedDespiteMark.add(child);
|
|
260
|
+
} else {
|
|
261
|
+
child.setQueuedFor(ownMark);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.#push(child);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
drain(): void {
|
|
269
|
+
const token = toToken(this);
|
|
270
|
+
|
|
271
|
+
const mut_buckets = this.#mut_buckets;
|
|
272
|
+
|
|
273
|
+
// `size` grows while the buckets are visited, always past `index`, which
|
|
274
|
+
// a `range` built at the start would miss.
|
|
275
|
+
// eslint-disable-next-line ts-data-forge/prefer-range-for-loop
|
|
276
|
+
for (let mut_index = 0; mut_index < this.#mut_size; mut_index += 1) {
|
|
277
|
+
const mut_bucket = mut_buckets[mut_index];
|
|
278
|
+
|
|
279
|
+
if (mut_bucket === undefined) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A node visited here queues only deeper nodes, so this bucket does
|
|
284
|
+
// not grow while it is visited. Its length is kept until the end, so
|
|
285
|
+
// that `close` can clear what an exception leaves.
|
|
286
|
+
const { nodes: mut_nodes, length } = mut_bucket;
|
|
287
|
+
|
|
288
|
+
// eslint-disable-next-line ts-data-forge/prefer-range-for-loop
|
|
289
|
+
for (let mut_slot = 0; mut_slot < length; mut_slot += 1) {
|
|
290
|
+
const node = mut_nodes[mut_slot];
|
|
291
|
+
|
|
292
|
+
// Drops the reference, so that a node completed and dropped by
|
|
293
|
+
// everything else is not kept alive by a slot.
|
|
294
|
+
mut_nodes[mut_slot] = undefined;
|
|
295
|
+
|
|
296
|
+
if (node !== undefined && !node.isCompleted) {
|
|
297
|
+
node.tryUpdate(token);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
mut_bucket.length = 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.#mut_size = 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
close(): void {
|
|
308
|
+
const mut_buckets = this.#mut_buckets;
|
|
309
|
+
|
|
310
|
+
// Only after an exception is anything left.
|
|
311
|
+
// eslint-disable-next-line ts-data-forge/prefer-range-for-loop
|
|
312
|
+
for (let mut_index = 0; mut_index < this.#mut_size; mut_index += 1) {
|
|
313
|
+
const mut_bucket = mut_buckets[mut_index];
|
|
314
|
+
|
|
315
|
+
if (mut_bucket === undefined) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
mut_bucket.nodes.fill(undefined, 0, mut_bucket.length);
|
|
320
|
+
|
|
321
|
+
mut_bucket.length = 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.#mut_size = 0;
|
|
325
|
+
|
|
326
|
+
this.#mut_isOpen = false;
|
|
327
|
+
|
|
328
|
+
this.#mut_queuedDespiteMark = undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A token as the queue it is. A token reaches this copy from a node of any
|
|
334
|
+
* copy, and every copy's tokens have `enqueueChildren`.
|
|
335
|
+
*/
|
|
336
|
+
const toQueue = (updateToken: UpdateToken): UpdateQueue =>
|
|
337
|
+
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
338
|
+
updateToken as unknown as UpdateQueue;
|
|
339
|
+
|
|
340
|
+
const toToken = (queue: UpdateQueue): UpdateToken =>
|
|
341
|
+
// @sumi-expect-error banned-syntax/no-unsafe-type-assertion
|
|
342
|
+
queue as unknown as UpdateToken;
|
|
@@ -202,8 +202,8 @@ describe('a completed observable leaves the propagation graph', () => {
|
|
|
202
202
|
|
|
203
203
|
const mut_got: number[] = [];
|
|
204
204
|
|
|
205
|
-
// Subscribed on a child, so the node is created while `s`
|
|
206
|
-
//
|
|
205
|
+
// Subscribed on a child, so the node is created while the update of `s`
|
|
206
|
+
// is under way.
|
|
207
207
|
s.pipe(map((x) => x)).subscribe((x) => {
|
|
208
208
|
if (x === 1) {
|
|
209
209
|
s.pipe(
|