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
|
@@ -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(
|
|
@@ -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
|
+
};
|