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.
Files changed (66) hide show
  1. package/README.md +58 -1
  2. package/dist/core/class/child-observable-class.d.mts +4 -7
  3. package/dist/core/class/child-observable-class.d.mts.map +1 -1
  4. package/dist/core/class/child-observable-class.mjs +8 -25
  5. package/dist/core/class/child-observable-class.mjs.map +1 -1
  6. package/dist/core/class/create-child-observable.d.mts +8 -5
  7. package/dist/core/class/create-child-observable.d.mts.map +1 -1
  8. package/dist/core/class/create-child-observable.mjs.map +1 -1
  9. package/dist/core/class/manager-observable-class.d.mts +4 -15
  10. package/dist/core/class/manager-observable-class.d.mts.map +1 -1
  11. package/dist/core/class/manager-observable-class.mjs +5 -91
  12. package/dist/core/class/manager-observable-class.mjs.map +1 -1
  13. package/dist/core/class/observable-base-class.d.mts +9 -2
  14. package/dist/core/class/observable-base-class.d.mts.map +1 -1
  15. package/dist/core/class/observable-base-class.mjs +17 -0
  16. package/dist/core/class/observable-base-class.mjs.map +1 -1
  17. package/dist/core/class/observable-node.d.mts +4 -8
  18. package/dist/core/class/observable-node.d.mts.map +1 -1
  19. package/dist/core/class/observable-node.mjs.map +1 -1
  20. package/dist/core/class/propagation-state.d.mts +26 -0
  21. package/dist/core/class/propagation-state.d.mts.map +1 -1
  22. package/dist/core/class/propagation-state.mjs +190 -0
  23. package/dist/core/class/propagation-state.mjs.map +1 -1
  24. package/dist/core/operators/index.d.mts +1 -0
  25. package/dist/core/operators/index.d.mts.map +1 -1
  26. package/dist/core/operators/index.mjs +1 -0
  27. package/dist/core/operators/index.mjs.map +1 -1
  28. package/dist/core/operators/keep-equal-parts.d.mts +85 -0
  29. package/dist/core/operators/keep-equal-parts.d.mts.map +1 -0
  30. package/dist/core/operators/keep-equal-parts.mjs +129 -0
  31. package/dist/core/operators/keep-equal-parts.mjs.map +1 -0
  32. package/dist/core/types/id.d.mts +6 -2
  33. package/dist/core/types/id.d.mts.map +1 -1
  34. package/dist/core/types/observable-family.d.mts +1 -0
  35. package/dist/core/types/observable-family.d.mts.map +1 -1
  36. package/dist/core/utils/id-maker.d.mts +0 -8
  37. package/dist/core/utils/id-maker.d.mts.map +1 -1
  38. package/dist/core/utils/id-maker.mjs +1 -11
  39. package/dist/core/utils/id-maker.mjs.map +1 -1
  40. package/dist/utils/create-reducer.d.mts +30 -1
  41. package/dist/utils/create-reducer.d.mts.map +1 -1
  42. package/dist/utils/create-reducer.mjs +8 -1
  43. package/dist/utils/create-reducer.mjs.map +1 -1
  44. package/dist/utils/create-state.d.mts +53 -1
  45. package/dist/utils/create-state.d.mts.map +1 -1
  46. package/dist/utils/create-state.mjs +52 -0
  47. package/dist/utils/create-state.mjs.map +1 -1
  48. package/package.json +9 -9
  49. package/src/core/class/child-observable-class.mts +15 -49
  50. package/src/core/class/create-child-observable.mts +8 -5
  51. package/src/core/class/manager-observable-class.mts +15 -134
  52. package/src/core/class/observable-base-class.mts +23 -2
  53. package/src/core/class/observable-node.mts +5 -11
  54. package/src/core/class/propagation-order.test.mts +107 -0
  55. package/src/core/class/propagation-state.mts +262 -0
  56. package/src/core/class/remove-completed.test.mts +2 -2
  57. package/src/core/class/update-loop.test.mts +104 -0
  58. package/src/core/operators/index.mts +1 -0
  59. package/src/core/operators/keep-equal-parts.mts +167 -0
  60. package/src/core/operators/keep-equal-parts.test.mts +132 -0
  61. package/src/core/types/id.mts +6 -2
  62. package/src/core/types/observable-family.mts +5 -0
  63. package/src/core/utils/id-maker.mts +1 -12
  64. package/src/utils/create-reducer.mts +42 -2
  65. package/src/utils/create-state.mts +53 -1
  66. 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
- /** Stores `nextValue` as the current value and notifies subscribers. */
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' and managers' lists. */
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 managers this node's own descendants register to: itself for a
24
- * manager, its parents' for a sync child, shared with its parent when it
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
- managers: readonly ManagerNode[];
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` is already
206
- // walking its propagation order.
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
+ };
@@ -1,6 +1,7 @@
1
1
  export * from './audit.mjs';
2
2
  export * from './debounce.mjs';
3
3
  export * from './filter.mjs';
4
+ export * from './keep-equal-parts.mjs';
4
5
  export * from './map.mjs';
5
6
  export * from './merge-map.mjs';
6
7
  export * from './pairwise.mjs';