synstate 6.2.1 → 7.0.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.
Files changed (109) hide show
  1. package/dist/core/class/child-observable-class.d.mts +16 -14
  2. package/dist/core/class/child-observable-class.d.mts.map +1 -1
  3. package/dist/core/class/child-observable-class.mjs +39 -12
  4. package/dist/core/class/child-observable-class.mjs.map +1 -1
  5. package/dist/core/class/create-child-observable.d.mts +1 -1
  6. package/dist/core/class/create-child-observable.d.mts.map +1 -1
  7. package/dist/core/class/create-child-observable.mjs +6 -8
  8. package/dist/core/class/create-child-observable.mjs.map +1 -1
  9. package/dist/core/class/create-root-observable.d.mts +1 -1
  10. package/dist/core/class/create-root-observable.d.mts.map +1 -1
  11. package/dist/core/class/create-root-observable.mjs +1 -5
  12. package/dist/core/class/create-root-observable.mjs.map +1 -1
  13. package/dist/core/class/propagation-state.mjs +1 -1
  14. package/dist/core/class/propagation-state.mjs.map +1 -1
  15. package/dist/core/combine/derive.d.mts.map +1 -1
  16. package/dist/core/combine/derive.mjs +2 -1
  17. package/dist/core/combine/derive.mjs.map +1 -1
  18. package/dist/core/combine/zip.d.mts.map +1 -1
  19. package/dist/core/combine/zip.mjs +5 -2
  20. package/dist/core/combine/zip.mjs.map +1 -1
  21. package/dist/core/create/counter.mjs +2 -2
  22. package/dist/core/create/counter.mjs.map +1 -1
  23. package/dist/core/create/from-abortable-promise.d.mts.map +1 -1
  24. package/dist/core/create/from-abortable-promise.mjs +10 -3
  25. package/dist/core/create/from-abortable-promise.mjs.map +1 -1
  26. package/dist/core/create/from-promise.d.mts.map +1 -1
  27. package/dist/core/create/from-promise.mjs +10 -3
  28. package/dist/core/create/from-promise.mjs.map +1 -1
  29. package/dist/core/create/source.d.mts.map +1 -1
  30. package/dist/core/create/source.mjs +2 -1
  31. package/dist/core/create/source.mjs.map +1 -1
  32. package/dist/core/operators/filter.d.mts +1 -1
  33. package/dist/core/operators/filter.d.mts.map +1 -1
  34. package/dist/core/operators/filter.mjs +2 -3
  35. package/dist/core/operators/filter.mjs.map +1 -1
  36. package/dist/core/operators/merge-map.d.mts +6 -2
  37. package/dist/core/operators/merge-map.d.mts.map +1 -1
  38. package/dist/core/operators/merge-map.mjs +47 -16
  39. package/dist/core/operators/merge-map.mjs.map +1 -1
  40. package/dist/core/operators/skip-while.d.mts +1 -1
  41. package/dist/core/operators/skip-while.d.mts.map +1 -1
  42. package/dist/core/operators/skip-while.mjs +9 -6
  43. package/dist/core/operators/skip-while.mjs.map +1 -1
  44. package/dist/core/operators/switch-map.d.mts +4 -0
  45. package/dist/core/operators/switch-map.d.mts.map +1 -1
  46. package/dist/core/operators/switch-map.mjs +16 -6
  47. package/dist/core/operators/switch-map.mjs.map +1 -1
  48. package/dist/core/operators/take-while.d.mts +2 -2
  49. package/dist/core/operators/take-while.d.mts.map +1 -1
  50. package/dist/core/operators/take-while.mjs +10 -8
  51. package/dist/core/operators/take-while.mjs.map +1 -1
  52. package/dist/core/operators/throttle.mjs +3 -2
  53. package/dist/core/operators/throttle.mjs.map +1 -1
  54. package/dist/core/operators/with-buffered-from.d.mts.map +1 -1
  55. package/dist/core/operators/with-buffered-from.mjs +13 -3
  56. package/dist/core/operators/with-buffered-from.mjs.map +1 -1
  57. package/dist/core/predefined/operators/attach-index.d.mts +1 -1
  58. package/dist/core/predefined/operators/attach-index.d.mts.map +1 -1
  59. package/dist/core/predefined/operators/attach-index.mjs +2 -3
  60. package/dist/core/predefined/operators/attach-index.mjs.map +1 -1
  61. package/dist/core/predefined/operators/skip.d.mts.map +1 -1
  62. package/dist/core/predefined/operators/skip.mjs +2 -2
  63. package/dist/core/predefined/operators/skip.mjs.map +1 -1
  64. package/dist/core/utils/index.d.mts +2 -0
  65. package/dist/core/utils/index.d.mts.map +1 -1
  66. package/dist/core/utils/index.mjs +2 -0
  67. package/dist/core/utils/index.mjs.map +1 -1
  68. package/dist/core/utils/is-development-build.d.mts +15 -0
  69. package/dist/core/utils/is-development-build.d.mts.map +1 -0
  70. package/dist/core/utils/is-development-build.mjs +23 -0
  71. package/dist/core/utils/is-development-build.mjs.map +1 -0
  72. package/dist/core/utils/rethrow-out-of-promise.d.mts +10 -0
  73. package/dist/core/utils/rethrow-out-of-promise.d.mts.map +1 -0
  74. package/dist/core/utils/rethrow-out-of-promise.mjs +17 -0
  75. package/dist/core/utils/rethrow-out-of-promise.mjs.map +1 -0
  76. package/package.json +12 -11
  77. package/src/core/class/child-observable-class.mts +53 -17
  78. package/src/core/class/circular-dependency-comparison.test.mts +36 -40
  79. package/src/core/class/circular-dependency.test.mts +6 -37
  80. package/src/core/class/create-child-observable.mts +8 -5
  81. package/src/core/class/create-root-observable.mts +2 -1
  82. package/src/core/class/propagation-state.mts +1 -1
  83. package/src/core/class/write-during-propagation.test.mts +75 -4
  84. package/src/core/combine/derive.mts +2 -1
  85. package/src/core/combine/zip.mts +5 -2
  86. package/src/core/create/counter.mts +2 -2
  87. package/src/core/create/from-abortable-promise.mts +33 -23
  88. package/src/core/create/from-promise.mts +28 -18
  89. package/src/core/create/from-promise.test.mts +97 -0
  90. package/src/core/create/source.mts +2 -1
  91. package/src/core/operators/filter.mts +2 -3
  92. package/src/core/operators/flat-map-initial-value.test.mts +269 -0
  93. package/src/core/operators/flat-map-sync-inner.test.mts +4 -2
  94. package/src/core/operators/inner-ownership.test.mts +145 -2
  95. package/src/core/operators/merge-map.mts +56 -26
  96. package/src/core/operators/skip-while.mts +12 -8
  97. package/src/core/operators/skip-while.test.mts +59 -0
  98. package/src/core/operators/switch-map.mts +21 -10
  99. package/src/core/operators/take-while.mts +15 -17
  100. package/src/core/operators/take-while.test.mts +51 -0
  101. package/src/core/operators/throttle.mts +4 -3
  102. package/src/core/operators/with-buffered-from.mts +16 -3
  103. package/src/core/operators/with-buffered-from.test.mts +27 -0
  104. package/src/core/predefined/operators/attach-index.mts +2 -3
  105. package/src/core/predefined/operators/skip.mts +2 -2
  106. package/src/core/utils/index.mts +2 -0
  107. package/src/core/utils/is-development-build.mts +32 -0
  108. package/src/core/utils/is-development-build.test.mts +44 -0
  109. package/src/core/utils/rethrow-out-of-promise.mts +16 -0
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether this is a development build: `NODE_ENV` is not `production`.
3
+ *
4
+ * `process.env.NODE_ENV` is written out in full because that is the expression
5
+ * bundlers (Vite, webpack, esbuild) replace, in the application's build and in
6
+ * a pre-bundled dependency alike: with the string `"development"` in a
7
+ * development build and `"production"` in a production one. Where nothing
8
+ * replaces it and there is no `process` (an unbundled module in a browser),
9
+ * reading it throws and the answer is `false`.
10
+ *
11
+ * synstate-router reads it the same way
12
+ * (`libs/synstate-router/src/internal/is-development.mts`).
13
+ */
14
+ export declare const isDevelopmentBuild: () => boolean;
15
+ //# sourceMappingURL=is-development-build.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-development-build.d.mts","sourceRoot":"","sources":["../../../src/core/utils/is-development-build.mts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,QAAO,OAOrC,CAAC"}
@@ -0,0 +1,23 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+ export const isDevelopmentBuild = () => {
15
+
16
+ try {
17
+ return process.env.NODE_ENV !== 'production';
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ };
23
+ //# sourceMappingURL=is-development-build.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-development-build.mjs","sourceRoot":"","sources":["../../../src/core/utils/is-development-build.mts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAY,EAAE;IAC9C,uCAAuC;IACvC,IAAI,CAAC;QACH,OAAO,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Throws `error` again from a task of its own, for a `.catch` after each step
3
+ * of a promise chain that runs subscribers (`fromPromise`): after the step that
4
+ * emits and after the one that completes, so that neither exception replaces
5
+ * the other. A subscriber's exception then surfaces as an uncaught error, as
6
+ * one thrown during an update a timer started does (`debounce`, `timer`),
7
+ * rather than as a rejection or not at all.
8
+ */
9
+ export declare const rethrowOutOfPromise: (error: unknown) => void;
10
+ //# sourceMappingURL=rethrow-out-of-promise.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rethrow-out-of-promise.d.mts","sourceRoot":"","sources":["../../../src/core/utils/rethrow-out-of-promise.mts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,UAAW,OAAO,KAAG,IAOpD,CAAC"}
@@ -0,0 +1,17 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ export const rethrowOutOfPromise = (error) => {
10
+ setTimeout(() => {
11
+
12
+
13
+
14
+ throw error;
15
+ }, 0);
16
+ };
17
+ //# sourceMappingURL=rethrow-out-of-promise.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rethrow-out-of-promise.mjs","sourceRoot":"","sources":["../../../src/core/utils/rethrow-out-of-promise.mts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,KAAc,EAAQ,EAAE;IAC1D,UAAU,CAAC,GAAG,EAAE;QACd,uDAAuD;QACvD,yCAAyC;QACzC,sHAAsH;QACtH,MAAM,KAAK,CAAC;IACd,CAAC,EAAE,CAAC,CAAC,CAAC;AACR,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synstate",
3
- "version": "6.2.1",
3
+ "version": "7.0.1",
4
4
  "description": "Type-safe State Management Library for TypeScript/JavaScript",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,21 +29,22 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "dependencies": {
32
- "ts-data-forge": "^14.10.2",
33
- "ts-std-forge": "^0.5.2",
32
+ "ts-data-forge": "^14.11.0",
33
+ "ts-std-forge": "^0.5.3",
34
34
  "ts-type-forge": "^9.2.2"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@preact/signals-core": "1.14.4",
38
38
  "@reduxjs/toolkit": "2.13.0",
39
- "@sumi-lang/cli": "0.1.5",
39
+ "@sumi-lang/cli": "0.1.7",
40
40
  "@types/react": "19.3.0",
41
41
  "@vitest/browser-playwright": "5.0.5",
42
42
  "argparse": "3.0.3",
43
- "eslint-config-typed": "7.3.1",
44
- "eslint-plugin-ts-data-forge": "0.10.8",
45
- "eslint-plugin-ts-fortress": "1.3.12",
46
- "eslint-plugin-ts-type-forge": "0.8.17",
43
+ "eslint": "10.13.0",
44
+ "eslint-config-typed": "7.5.0",
45
+ "eslint-plugin-ts-data-forge": "0.10.10",
46
+ "eslint-plugin-ts-fortress": "1.3.14",
47
+ "eslint-plugin-ts-type-forge": "0.8.19",
47
48
  "jotai": "3.0.1",
48
49
  "mobx": "7.1.0",
49
50
  "playwright": "1.64.1",
@@ -53,9 +54,9 @@
53
54
  "react": "19.3.1",
54
55
  "rxjs": "7.8.2",
55
56
  "solid-js": "1.9.17",
56
- "synstate-react-hooks": "6.2.1",
57
- "synstate-react-hooks-compat": "6.2.1",
58
- "ts-repo-utils": "10.7.1",
57
+ "synstate-react-hooks": "7.0.1",
58
+ "synstate-react-hooks-compat": "7.0.1",
59
+ "ts-repo-utils": "10.7.3",
59
60
  "valtio": "2.3.2",
60
61
  "zustand": "5.0.15"
61
62
  },
@@ -2,7 +2,7 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { hasKey, isRecord, type Optional, type Some } from 'ts-data-forge';
5
+ import { Optional, type Some } from 'ts-data-forge';
6
6
  import { panic } from 'ts-std-forge';
7
7
  import type { MutableSet } from 'ts-type-forge';
8
8
  import type {
@@ -20,7 +20,11 @@ import type {
20
20
  WithInitialValueOperator,
21
21
  Wrap,
22
22
  } from '../types/index.mjs';
23
- import { maxDepth } from '../utils/index.mjs';
23
+ import {
24
+ isDevelopmentBuild,
25
+ issueUnqueuedToken,
26
+ maxDepth,
27
+ } from '../utils/index.mjs';
24
28
  import { ManagerObservableClass } from './manager-observable-class.mjs';
25
29
  import { ObservableBaseClass } from './observable-base-class.mjs';
26
30
  import { toNode, type ChildNode } from './observable-node.mjs';
@@ -198,6 +202,45 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
198
202
  });
199
203
  }
200
204
 
205
+ /**
206
+ * `attach`, then projects the value `parent` holds when `switchMap` /
207
+ * `mergeMap` is created, as `map` and `filter` start from it; a completed
208
+ * parent is not projected. Nothing observes the operator yet, so a value the
209
+ * inner observable emits while it is subscribed is only stored: the token is
210
+ * one no update is queued with.
211
+ *
212
+ * If the projection throws, the operator is disposed, which disposes what the
213
+ * projection made and takes the operator off its parent, and the exception
214
+ * goes on to the caller of `pipe`.
215
+ */
216
+ protected attachProjectingHeldValue<V>(
217
+ parent: Observable<V>,
218
+ project: (value: V, updateToken: UpdateToken) => void,
219
+ ): void {
220
+ this.attach();
221
+
222
+ const sn = parent.getSnapshot();
223
+
224
+ if (this.isCompleted || Optional.isNone(sn)) {
225
+ return;
226
+ }
227
+
228
+ // A flag and `finally` rather than `catch` and rethrow: Sumi bans
229
+ // `try..catch`, and the exception is never caught on its way to `pipe`.
230
+ let mut_projected = false;
231
+
232
+ // @sumi-expect-error exceptions/no-try
233
+ try {
234
+ project(sn.value, issueUnqueuedToken());
235
+
236
+ mut_projected = true;
237
+ } finally {
238
+ if (!mut_projected) {
239
+ this.dispose();
240
+ }
241
+ }
242
+ }
243
+
201
244
  /**
202
245
  * Subscribes to the inner observable that the projection of `switchMap` /
203
246
  * `mergeMap` returned.
@@ -212,10 +255,13 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
212
255
  * glitch), and would emit twice.
213
256
  *
214
257
  * Values the inner observable emits later start updates of their own.
258
+ * `onComplete` is called when it completes, which may be while it is being
259
+ * subscribed, before this returns.
215
260
  */
216
261
  protected subscribeInner(
217
262
  inner: Observable<A>,
218
263
  updateToken: UpdateToken,
264
+ onComplete?: () => void,
219
265
  ): Subscription {
220
266
  let mut_isSubscribing = true;
221
267
 
@@ -225,7 +271,7 @@ export class AsyncChildObservableClass<A, const P extends NonEmptyUnknownList>
225
271
  } else {
226
272
  this.startUpdate(value);
227
273
  }
228
- });
274
+ }, onComplete);
229
275
 
230
276
  mut_isSubscribing = false;
231
277
 
@@ -353,17 +399,6 @@ const unregisterChild = (child: ChildNode): void => {
353
399
  }
354
400
  };
355
401
 
356
- const isDevelopmentProcess = (): boolean => {
357
- const proc: unknown = Reflect.get(globalThis, 'process');
358
-
359
- return (
360
- isRecord(proc) &&
361
- hasKey(proc, 'env') &&
362
- isRecord(proc.env) &&
363
- (!hasKey(proc.env, 'NODE_ENV') || proc.env.NODE_ENV !== 'production')
364
- );
365
- };
366
-
367
402
  /**
368
403
  * Whether {@link registerChild} checks for a circular dependency: on in
369
404
  * development, off in production. The public API cannot make a cycle — a
@@ -372,7 +407,8 @@ const isDevelopmentProcess = (): boolean => {
372
407
  * node) against code that rewrites an observable's readonly `parents`, and
373
408
  * development is where that code runs first. It walks every ancestor of each
374
409
  * new observable, a cost production does not pay: the check is off when
375
- * `NODE_ENV` is `production`, and where there is no `process` (a browser
376
- * bundle).
410
+ * `NODE_ENV` is `production`, including a bundle whose bundler replaced
411
+ * `process.env.NODE_ENV`, and where nothing replaced it and there is no
412
+ * `process` (see {@link isDevelopmentBuild}).
377
413
  */
378
- export const checksCircularDependency: boolean = isDevelopmentProcess();
414
+ const checksCircularDependency: boolean = isDevelopmentBuild();
@@ -14,7 +14,6 @@ import { Optional } from 'ts-data-forge';
14
14
  import { combine } from '../combine/index.mjs';
15
15
  import { source } from '../create/index.mjs';
16
16
  import { map } from '../operators/index.mjs';
17
- import { checksCircularDependency } from './child-observable-class.mjs';
18
17
  import {
19
18
  createSyncChildObservable,
20
19
  createTryUpdateNotImplemented,
@@ -23,53 +22,50 @@ import { createRootObservable } from './create-root-observable.mjs';
23
22
 
24
23
  describe('circular dependency comparison', () => {
25
24
  describe('SynState', () => {
26
- // Only where the check runs: see `checksCircularDependency`.
27
- test.runIf(checksCircularDependency)(
28
- 'detects cycle at construction time with a clear error message',
29
- () => {
30
- const root = createRootObservable(
31
- { initialValue: Optional.some(0) },
32
- () => ({}),
33
- );
25
+ // The check is on in both test runs: see `circular-dependency.test.mts`.
26
+ test('detects cycle at construction time with a clear error message', () => {
27
+ const root = createRootObservable(
28
+ { initialValue: Optional.some(0) },
29
+ () => ({}),
30
+ );
34
31
 
35
- const mut_childA = createSyncChildObservable(
36
- {
37
- parents: [root],
38
- initialValue: Optional.some(0),
39
- },
40
- createTryUpdateNotImplemented,
41
- );
32
+ const mut_childA = createSyncChildObservable(
33
+ {
34
+ parents: [root],
35
+ initialValue: Optional.some(0),
36
+ },
37
+ createTryUpdateNotImplemented,
38
+ );
42
39
 
43
- const childB = createSyncChildObservable(
40
+ const childB = createSyncChildObservable(
41
+ {
42
+ parents: [mut_childA],
43
+ initialValue: Optional.some(0),
44
+ },
45
+ createTryUpdateNotImplemented,
46
+ );
47
+
48
+ // Simulate a cycle: mut_childA → childB → mut_childA
49
+ // In normal usage this is impossible — the check runs in every child
50
+ // constructor before the reference becomes available.
51
+ Object.defineProperty(mut_childA, 'parents', {
52
+ value: [childB],
53
+ writable: false,
54
+ configurable: true,
55
+ });
56
+
57
+ expect(() => {
58
+ createSyncChildObservable(
44
59
  {
45
60
  parents: [mut_childA],
46
61
  initialValue: Optional.some(0),
47
62
  },
48
63
  createTryUpdateNotImplemented,
49
64
  );
50
-
51
- // Simulate a cycle: mut_childA → childB → mut_childA
52
- // In normal usage this is impossible — the check runs in every child
53
- // constructor before the reference becomes available.
54
- Object.defineProperty(mut_childA, 'parents', {
55
- value: [childB],
56
- writable: false,
57
- configurable: true,
58
- });
59
-
60
- expect(() => {
61
- createSyncChildObservable(
62
- {
63
- parents: [mut_childA],
64
- initialValue: Optional.some(0),
65
- },
66
- createTryUpdateNotImplemented,
67
- );
68
- }).toThrow(
69
- 'Circular dependency detected in observable graph: a child observable cannot be its own ancestor.',
70
- );
71
- },
72
- );
65
+ }).toThrow(
66
+ 'Circular dependency detected in observable graph: a child observable cannot be its own ancestor.',
67
+ );
68
+ });
73
69
 
74
70
  test('accepts valid DAG (diamond dependency)', () => {
75
71
  const a$ = source(0);
@@ -2,7 +2,6 @@ import { Optional } from 'ts-data-forge';
2
2
  import { combine, merge } from '../combine/index.mjs';
3
3
  import { source } from '../create/index.mjs';
4
4
  import { map } from '../operators/index.mjs';
5
- import { checksCircularDependency } from './child-observable-class.mjs';
6
5
  import {
7
6
  createAsyncChildObservable,
8
7
  createSyncChildObservable,
@@ -11,9 +10,12 @@ import {
11
10
  import { createRootObservable } from './create-root-observable.mjs';
12
11
 
13
12
  describe('circular dependency detection', () => {
14
- // The check runs only in a process whose `NODE_ENV` is not `production`;
15
- // the browser run has no `process`, and there nothing is checked.
16
- describe.runIf(checksCircularDependency)('cycle in ancestor graph', () => {
13
+ // The check runs only where `NODE_ENV` is not `production`, which is so in
14
+ // both test runs: in the browser run because Vite replaced
15
+ // `process.env.NODE_ENV`, so these tests also fail if it stops being
16
+ // replaced. `test/circular-dependency-check-off.test.mts` loads a production
17
+ // copy.
18
+ describe('cycle in ancestor graph', () => {
17
19
  test('should throw when parent chain contains a cycle (A -> B -> A)', () => {
18
20
  const root = createRootObservable(
19
21
  { initialValue: Optional.some(0) },
@@ -183,39 +185,6 @@ describe('circular dependency detection', () => {
183
185
  });
184
186
  });
185
187
 
186
- describe.skipIf(checksCircularDependency)('with the check off', () => {
187
- test('does not walk the ancestors of a new child', () => {
188
- const root = createRootObservable(
189
- { initialValue: Optional.some(0) },
190
- () => ({}),
191
- );
192
-
193
- const mut_childA = createSyncChildObservable(
194
- {
195
- parents: [root],
196
- initialValue: Optional.some(0),
197
- },
198
- createTryUpdateNotImplemented,
199
- );
200
-
201
- Object.defineProperty(mut_childA, 'parents', {
202
- value: [mut_childA],
203
- writable: false,
204
- configurable: true,
205
- });
206
-
207
- expect(() => {
208
- createSyncChildObservable(
209
- {
210
- parents: [mut_childA],
211
- initialValue: Optional.some(0),
212
- },
213
- createTryUpdateNotImplemented,
214
- );
215
- }).not.toThrow();
216
- });
217
- });
218
-
219
188
  describe('valid DAG patterns should not throw', () => {
220
189
  test('diamond dependency is not a cycle', () => {
221
190
  const root = createRootObservable(
@@ -2,7 +2,8 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { Arr, type Optional, type Some } from 'ts-data-forge';
5
+ import type { Optional, Some } from 'ts-data-forge';
6
+ import * as Arr from 'ts-data-forge/array';
6
7
  import { unreachable } from 'ts-std-forge';
7
8
  import type {
8
9
  AsyncChildObservable,
@@ -300,7 +301,9 @@ class CustomAsyncChildObservableClass<
300
301
  const P extends NonEmptyUnknownList,
301
302
  > extends AsyncChildObservableClass<A, P> {
302
303
  #mut_teardowns: readonly (() => void)[] | undefined;
303
- readonly #hooks: AsyncChildObservableHooks;
304
+ // Undefined until `init` returns, which may complete the observable first;
305
+ // `complete` then calls `flush` before there is a hook to call.
306
+ readonly #hooks: AsyncChildObservableHooks | undefined;
304
307
 
305
308
  constructor(
306
309
  config: ChildObservableConfig<A, P>,
@@ -344,15 +347,15 @@ class CustomAsyncChildObservableClass<
344
347
  }
345
348
 
346
349
  override tryUpdate(updateToken: UpdateToken): void {
347
- this.#hooks.tryUpdate(updateToken);
350
+ this.#hooks?.tryUpdate(updateToken);
348
351
  }
349
352
 
350
353
  override canEmitNoMore(): boolean {
351
- return this.#hooks.canEmitNoMore?.() === true;
354
+ return this.#hooks?.canEmitNoMore?.() === true;
352
355
  }
353
356
 
354
357
  protected override flush(): void {
355
- this.#hooks.flush?.();
358
+ this.#hooks?.flush?.();
356
359
  }
357
360
 
358
361
  protected override teardown(): void {
@@ -2,7 +2,8 @@
2
2
  // @sumi-expect-error-file banned-syntax/no-this
3
3
  // @sumi-expect-error-file functions/prefer-arrow-function
4
4
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
5
- import { Arr, type Optional } from 'ts-data-forge';
5
+ import type { Optional } from 'ts-data-forge';
6
+ import * as Arr from 'ts-data-forge/array';
6
7
  import type { ReadonlyRecord } from 'ts-type-forge';
7
8
  import type { RootObservable } from '../types/index.mjs';
8
9
  import { RootObservableClass } from './root-observable-class.mjs';
@@ -11,7 +11,7 @@
11
11
  * costs about twice as much per access as a module binding, on a path every
12
12
  * update takes.
13
13
  */
14
- import { Arr } from 'ts-data-forge';
14
+ import * as Arr from 'ts-data-forge/array';
15
15
  import { panic } from 'ts-std-forge';
16
16
  import type { MutableSet } from 'ts-type-forge';
17
17
  import type { UpdateToken } from '../types/index.mjs';
@@ -4,7 +4,12 @@ import type { FixedLengthTuple } from 'ts-type-forge';
4
4
  import { createState } from '../../utils/index.mjs';
5
5
  import { combine } from '../combine/index.mjs';
6
6
  import { fromSubscribable, source } from '../create/index.mjs';
7
- import { map, switchMap } from '../operators/index.mjs';
7
+ import {
8
+ map,
9
+ switchMap,
10
+ throttle,
11
+ withBufferedFrom,
12
+ } from '../operators/index.mjs';
8
13
  import { writeDuringPropagationWarning } from './root-observable-class.mjs';
9
14
 
10
15
  describe('a write to a root observable while an update is propagating (#2336)', () => {
@@ -156,7 +161,8 @@ describe('a write to a root observable while an update is propagating (#2336)',
156
161
 
157
162
  assert.deepStrictEqual(warn, []);
158
163
 
159
- assert.deepStrictEqual(mut_history, [10]);
164
+ // 0 from the value `outer$` held when `switchMap` was created.
165
+ assert.deepStrictEqual(mut_history, [0, 10]);
160
166
  });
161
167
 
162
168
  test('does not warn when a root created during propagation emits while it is created', async () => {
@@ -188,7 +194,8 @@ describe('a write to a root observable while an update is propagating (#2336)',
188
194
 
189
195
  assert.deepStrictEqual(warn, []);
190
196
 
191
- assert.deepStrictEqual(mut_history, [10]);
197
+ // 0 from the value `outer$` held when `switchMap` was created.
198
+ assert.deepStrictEqual(mut_history, [0, 10]);
192
199
  });
193
200
 
194
201
  test('a subscriber that throws does not leave the propagation flag set', async () => {
@@ -214,12 +221,76 @@ describe('a write to a root observable while an update is propagating (#2336)',
214
221
  });
215
222
  });
216
223
 
224
+ describe('a stateful operator updates its state before setNext, which a write from a subscriber re-enters', () => {
225
+ test('withBufferedFrom keeps a value written to the buffered observable for the next batch', async () => {
226
+ const [data$, setData] = createState('a');
227
+
228
+ const [trigger$, setTrigger] = createState(0);
229
+
230
+ const result$ = trigger$.pipe(withBufferedFrom(data$));
231
+
232
+ const mut_history: (readonly [number, readonly string[]])[] = [];
233
+
234
+ result$.subscribe((v) => {
235
+ mut_history.push(v);
236
+
237
+ if (v[0] === 1) {
238
+ setData('b');
239
+ }
240
+ });
241
+
242
+ await captureWarnings(() => {
243
+ setTrigger(1);
244
+ });
245
+
246
+ setTrigger(2);
247
+
248
+ assert.deepStrictEqual(mut_history, [
249
+ [0, ['a']],
250
+ [1, []],
251
+ [2, ['b']],
252
+ ]);
253
+ });
254
+
255
+ test('throttle drops a value a subscriber writes to the parent inside the window, and keeps one timer', async () => {
256
+ vi.useFakeTimers();
257
+
258
+ onTestFinished(() => {
259
+ vi.useRealTimers();
260
+ });
261
+
262
+ const [src$, setSrc] = createState(0);
263
+
264
+ const throttled$ = src$.pipe(throttle(1000));
265
+
266
+ const mut_history: number[] = [];
267
+
268
+ throttled$.subscribe((v) => {
269
+ mut_history.push(v);
270
+
271
+ if (v === 1) {
272
+ setSrc(2);
273
+ }
274
+ });
275
+
276
+ await captureWarnings(() => {
277
+ setSrc(1);
278
+ });
279
+
280
+ assert.deepStrictEqual(mut_history, [0, 1]);
281
+
282
+ throttled$.complete();
283
+
284
+ assert.strictEqual(vi.getTimerCount(), 0);
285
+ });
286
+ });
287
+
217
288
  /**
218
289
  * Runs `run` with `console.warn` replaced by a mock, and returns the messages
219
290
  * it was called with.
220
291
  */
221
292
  const captureWarnings = async (
222
- run: () => Promise<void> | void,
293
+ run: () => Promise<void> | undefined,
223
294
  ): Promise<readonly unknown[]> => {
224
295
  const warn = vi.fn();
225
296
 
@@ -5,7 +5,8 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { Arr, Optional } from 'ts-data-forge';
8
+ import { Optional } from 'ts-data-forge';
9
+ import * as Arr from 'ts-data-forge/array';
9
10
  import type { NonEmptyTuple } from 'ts-type-forge';
10
11
  import { SyncChildObservableClass } from '../class/index.mjs';
11
12
  import type {
@@ -5,7 +5,8 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { Arr, Optional, createQueue, expectType } from 'ts-data-forge';
8
+ import { Optional, createQueue, expectType } from 'ts-data-forge';
9
+ import * as Arr from 'ts-data-forge/array';
9
10
  import type { NonEmptyTuple, Tuple } from 'ts-type-forge';
10
11
  import { SyncChildObservableClass } from '../class/index.mjs';
11
12
  import { source } from '../create/index.mjs';
@@ -150,8 +151,10 @@ class ZipObservableClass<const A extends NonEmptyUnknownList>
150
151
 
151
152
  this.setNext(nextValue, updateToken);
152
153
 
154
+ // After the update has reached what is derived from it, as in `take`
155
+ // (#2371).
153
156
  if (this.canEmitNoMore()) {
154
- this.complete();
157
+ this.completeAfterPropagation();
155
158
  }
156
159
  }
157
160
 
@@ -5,7 +5,7 @@
5
5
  // @sumi-expect-error-file banned-syntax/no-this
6
6
  // @sumi-expect-error-file functions/prefer-arrow-function
7
7
  // @sumi-expect-error-file mutation/no-mutation-without-mut-prefix
8
- import { asSafeUint, Optional, SafeUint } from 'ts-data-forge';
8
+ import { asSafeUint, Optional, type SafeUint } from 'ts-data-forge';
9
9
  import type { PositiveSafeIntWithSmallInt } from 'ts-type-forge';
10
10
  import { RootObservableClass } from '../class/index.mjs';
11
11
  import type { CounterObservable, TimerId } from '../types/index.mjs';
@@ -154,7 +154,7 @@ class CounterObservableClass
154
154
  }
155
155
 
156
156
  this.#mut_timerId = setInterval(() => {
157
- this.#mut_counter = SafeUint.add(1, this.#mut_counter);
157
+ this.#mut_counter = asSafeUint(this.#mut_counter + 1);
158
158
 
159
159
  this.#emit(this.#mut_counter);
160
160
  }, this.#intervalMilliSeconds);