@zeix/cause-effect 0.14.1 → 0.15.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 (45) hide show
  1. package/README.md +256 -27
  2. package/biome.json +35 -0
  3. package/index.d.ts +32 -7
  4. package/index.dev.js +629 -0
  5. package/index.js +1 -1
  6. package/index.ts +41 -21
  7. package/package.json +6 -7
  8. package/src/computed.ts +30 -21
  9. package/src/diff.ts +136 -0
  10. package/src/effect.ts +59 -49
  11. package/src/match.ts +57 -0
  12. package/src/resolve.ts +58 -0
  13. package/src/scheduler.ts +3 -3
  14. package/src/signal.ts +48 -15
  15. package/src/state.ts +4 -3
  16. package/src/store.ts +325 -0
  17. package/src/util.ts +57 -5
  18. package/test/batch.test.ts +29 -25
  19. package/test/benchmark.test.ts +81 -45
  20. package/test/computed.test.ts +43 -39
  21. package/test/diff.test.ts +638 -0
  22. package/test/effect.test.ts +657 -49
  23. package/test/match.test.ts +378 -0
  24. package/test/resolve.test.ts +156 -0
  25. package/test/state.test.ts +33 -33
  26. package/test/store.test.ts +719 -0
  27. package/test/util/framework-types.ts +2 -2
  28. package/test/util/perf-tests.ts +2 -2
  29. package/test/util/reactive-framework.ts +1 -1
  30. package/tsconfig.json +9 -10
  31. package/types/index.d.ts +15 -0
  32. package/{src → types/src}/computed.d.ts +2 -2
  33. package/types/src/diff.d.ts +27 -0
  34. package/types/src/effect.d.ts +16 -0
  35. package/types/src/match.d.ts +21 -0
  36. package/types/src/resolve.d.ts +29 -0
  37. package/{src → types/src}/scheduler.d.ts +2 -2
  38. package/types/src/signal.d.ts +40 -0
  39. package/{src → types/src}/state.d.ts +1 -1
  40. package/types/src/store.d.ts +57 -0
  41. package/types/src/util.d.ts +15 -0
  42. package/types/test-new-effect.d.ts +1 -0
  43. package/src/effect.d.ts +0 -17
  44. package/src/signal.d.ts +0 -26
  45. package/src/util.d.ts +0 -7
package/index.ts CHANGED
@@ -1,36 +1,56 @@
1
1
  /**
2
2
  * @name Cause & Effect
3
- * @version 0.14.1
3
+ * @version 0.15.0
4
4
  * @author Esther Brunner
5
5
  */
6
- export { isFunction, CircularDependencyError } from './src/util'
7
- export {
8
- type Signal,
9
- type MaybeSignal,
10
- type SignalValues,
11
- UNSET,
12
- isSignal,
13
- isComputedCallback,
14
- toSignal,
15
- } from './src/signal'
16
- export { type State, TYPE_STATE, state, isState } from './src/state'
6
+
17
7
  export {
18
8
  type Computed,
19
9
  type ComputedCallback,
20
- TYPE_COMPUTED,
21
10
  computed,
22
11
  isComputed,
12
+ isComputedCallback,
13
+ TYPE_COMPUTED,
23
14
  } from './src/computed'
24
- export { type EffectMatcher, effect } from './src/effect'
15
+ export { type DiffResult, diff, isEqual, type UnknownRecord } from './src/diff'
16
+ export { type EffectCallback, effect, type MaybeCleanup } from './src/effect'
17
+ export { type MatchHandlers, match } from './src/match'
18
+ export { type ResolveResult, resolve } from './src/resolve'
25
19
  export {
26
- type Watcher,
20
+ batch,
27
21
  type Cleanup,
28
- type Updater,
29
- watch,
30
- subscribe,
31
- notify,
22
+ enqueue,
32
23
  flush,
33
- batch,
24
+ notify,
34
25
  observe,
35
- enqueue,
26
+ subscribe,
27
+ type Updater,
28
+ type Watcher,
29
+ watch,
36
30
  } from './src/scheduler'
31
+ export {
32
+ isSignal,
33
+ type MaybeSignal,
34
+ type Signal,
35
+ type SignalValues,
36
+ toSignal,
37
+ UNSET,
38
+ } from './src/signal'
39
+ export { isState, type State, state, TYPE_STATE } from './src/state'
40
+ export {
41
+ isStore,
42
+ type Store,
43
+ type StoreAddEvent,
44
+ type StoreChangeEvent,
45
+ type StoreEventMap,
46
+ type StoreRemoveEvent,
47
+ store,
48
+ TYPE_STORE,
49
+ } from './src/store'
50
+ export {
51
+ CircularDependencyError,
52
+ isAbortError,
53
+ isAsyncFunction,
54
+ isFunction,
55
+ toError,
56
+ } from './src/util'
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@zeix/cause-effect",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "author": "Esther Brunner",
5
5
  "main": "index.js",
6
6
  "module": "index.ts",
7
7
  "devDependencies": {
8
+ "@biomejs/biome": "2.1.4",
8
9
  "@types/bun": "latest",
9
- "eslint": "^9.27.0",
10
- "random": "^5.4.0",
11
- "typescript-eslint": "^8.32.1"
10
+ "random": "^5.4.1"
12
11
  },
13
12
  "peerDependencies": {
14
13
  "typescript": "^5.6.3"
@@ -25,10 +24,10 @@
25
24
  "access": "public"
26
25
  },
27
26
  "scripts": {
28
- "build": "bun build index.ts --outdir ./ --minify && bunx tsc",
27
+ "build": "bunx tsc && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js",
29
28
  "test": "bun test",
30
- "lint": "bunx eslint src/"
29
+ "lint": "bunx biome lint --write"
31
30
  },
32
31
  "type": "module",
33
- "types": "index.d.ts"
32
+ "types": "types/index.d.ts"
34
33
  }
package/src/computed.ts CHANGED
@@ -1,18 +1,21 @@
1
+ import { isEqual } from './diff'
2
+ import {
3
+ flush,
4
+ notify,
5
+ observe,
6
+ subscribe,
7
+ type Watcher,
8
+ watch,
9
+ } from './scheduler'
10
+ import { UNSET } from './signal'
1
11
  import {
2
12
  CircularDependencyError,
13
+ isAbortError,
14
+ isAsyncFunction,
3
15
  isFunction,
4
16
  isObjectOfType,
5
17
  toError,
6
18
  } from './util'
7
- import {
8
- type Watcher,
9
- watch,
10
- subscribe,
11
- notify,
12
- flush,
13
- observe,
14
- } from './scheduler'
15
- import { UNSET } from './signal'
16
19
 
17
20
  /* === Types === */
18
21
 
@@ -20,7 +23,7 @@ type Computed<T extends {}> = {
20
23
  [Symbol.toStringTag]: 'Computed'
21
24
  get(): T
22
25
  }
23
- type ComputedCallback<T extends {} & { then?: void }> =
26
+ type ComputedCallback<T extends {} & { then?: undefined }> =
24
27
  | ((abort: AbortSignal) => Promise<T>)
25
28
  | (() => T)
26
29
 
@@ -49,20 +52,20 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
49
52
  let computing = false
50
53
 
51
54
  // Functions to update internal state
52
- const ok = (v: T) => {
53
- if (!Object.is(v, value)) {
55
+ const ok = (v: T): undefined => {
56
+ if (!isEqual(v, value)) {
54
57
  value = v
55
58
  changed = true
56
59
  }
57
60
  error = undefined
58
61
  dirty = false
59
62
  }
60
- const nil = () => {
63
+ const nil = (): undefined => {
61
64
  changed = UNSET !== value
62
65
  value = UNSET
63
66
  error = undefined
64
67
  }
65
- const err = (e: unknown) => {
68
+ const err = (e: unknown): undefined => {
66
69
  const newError = toError(e)
67
70
  changed =
68
71
  !error ||
@@ -83,25 +86,31 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
83
86
  // Own watcher: called when notified from sources (push)
84
87
  const mark = watch(() => {
85
88
  dirty = true
86
- controller?.abort('Aborted because source signal changed')
89
+ controller?.abort()
87
90
  if (watchers.size) notify(watchers)
88
91
  else mark.cleanup()
89
92
  })
93
+ mark.off(() => {
94
+ controller?.abort()
95
+ })
90
96
 
91
97
  // Called when requested by dependencies (pull)
92
98
  const compute = () =>
93
99
  observe(() => {
94
100
  if (computing) throw new CircularDependencyError('computed')
95
101
  changed = false
96
- if (isFunction(fn) && fn.constructor.name === 'AsyncFunction') {
97
- if (controller) return value // return current value until promise resolves
102
+ if (isAsyncFunction(fn)) {
103
+ // Return current value until promise resolves
104
+ if (controller) return value
98
105
  controller = new AbortController()
99
106
  controller.signal.addEventListener(
100
107
  'abort',
101
108
  () => {
102
109
  computing = false
103
110
  controller = undefined
104
- compute() // retry
111
+
112
+ // Retry computation with updated state
113
+ compute()
105
114
  },
106
115
  {
107
116
  once: true,
@@ -113,7 +122,7 @@ const computed = <T extends {}>(fn: ComputedCallback<T>): Computed<T> => {
113
122
  try {
114
123
  result = controller ? fn(controller.signal) : (fn as () => T)()
115
124
  } catch (e) {
116
- if (e instanceof DOMException && e.name === 'AbortError') nil()
125
+ if (isAbortError(e)) nil()
117
126
  else err(e)
118
127
  computing = false
119
128
  return
@@ -169,10 +178,10 @@ const isComputedCallback = /*#__PURE__*/ <T extends {}>(
169
178
  /* === Exports === */
170
179
 
171
180
  export {
172
- type Computed,
173
- type ComputedCallback,
174
181
  TYPE_COMPUTED,
175
182
  computed,
176
183
  isComputed,
177
184
  isComputedCallback,
185
+ type Computed,
186
+ type ComputedCallback,
178
187
  }
package/src/diff.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { UNSET } from './signal'
2
+ import { CircularDependencyError, isRecord } from './util'
3
+
4
+ /* === Types === */
5
+
6
+ type UnknownRecord = Record<string, unknown & {}>
7
+
8
+ type DiffResult<T extends UnknownRecord = UnknownRecord> = {
9
+ changed: boolean
10
+ add: Partial<T>
11
+ change: Partial<T>
12
+ remove: Partial<T>
13
+ }
14
+
15
+ /* === Functions === */
16
+
17
+ /**
18
+ * Checks if two values are equal with cycle detection
19
+ *
20
+ * @since 0.15.0
21
+ * @param {T} a - First value to compare
22
+ * @param {T} b - Second value to compare
23
+ * @param {WeakSet<object>} visited - Set to track visited objects for cycle detection
24
+ * @returns {boolean} Whether the two values are equal
25
+ */
26
+ const isEqual = <T>(a: T, b: T, visited?: WeakSet<object>): boolean => {
27
+ // Fast paths
28
+ if (Object.is(a, b)) return true
29
+ if (typeof a !== typeof b) return false
30
+ if (typeof a !== 'object' || a === null || b === null) return false
31
+
32
+ // Cycle detection
33
+ if (!visited) visited = new WeakSet()
34
+ if (visited.has(a as object) || visited.has(b as object))
35
+ throw new CircularDependencyError('isEqual')
36
+ visited.add(a as object)
37
+ visited.add(b as object)
38
+
39
+ try {
40
+ if (Array.isArray(a) && Array.isArray(b)) {
41
+ if (a.length !== b.length) return false
42
+ for (let i = 0; i < a.length; i++) {
43
+ if (!isEqual(a[i], b[i], visited)) return false
44
+ }
45
+ return true
46
+ }
47
+
48
+ if (Array.isArray(a) !== Array.isArray(b)) return false
49
+
50
+ if (isRecord(a) && isRecord(b)) {
51
+ const aKeys = Object.keys(a)
52
+ const bKeys = Object.keys(b)
53
+
54
+ if (aKeys.length !== bKeys.length) return false
55
+ for (const key of aKeys) {
56
+ if (!(key in b)) return false
57
+ if (
58
+ !isEqual(
59
+ (a as Record<string, unknown>)[key],
60
+ (b as Record<string, unknown>)[key],
61
+ visited,
62
+ )
63
+ )
64
+ return false
65
+ }
66
+ return true
67
+ }
68
+
69
+ return false
70
+ } finally {
71
+ visited.delete(a as object)
72
+ visited.delete(b as object)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Compares two records and returns a result object containing the differences.
78
+ *
79
+ * @since 0.15.0
80
+ * @param {T} oldObj - The old record to compare
81
+ * @param {T} newObj - The new record to compare
82
+ * @returns {DiffResult<T>} The result of the comparison
83
+ */
84
+ const diff = <T extends UnknownRecord>(oldObj: T, newObj: T): DiffResult<T> => {
85
+ const visited = new WeakSet<object>()
86
+
87
+ const diffRecords = (
88
+ oldRecord: Record<string, unknown>,
89
+ newRecord: Record<string, unknown>,
90
+ ): DiffResult<T> => {
91
+ const add: Partial<T> = {}
92
+ const change: Partial<T> = {}
93
+ const remove: Partial<T> = {}
94
+
95
+ const oldKeys = Object.keys(oldRecord)
96
+ const newKeys = Object.keys(newRecord)
97
+ const allKeys = new Set([...oldKeys, ...newKeys])
98
+
99
+ for (const key of allKeys) {
100
+ const oldHas = key in oldRecord
101
+ const newHas = key in newRecord
102
+
103
+ if (!oldHas && newHas) {
104
+ add[key as keyof T] = newRecord[key] as T[keyof T]
105
+ continue
106
+ } else if (oldHas && !newHas) {
107
+ remove[key as keyof T] = UNSET
108
+ continue
109
+ }
110
+
111
+ const oldValue = oldRecord[key] as T[keyof T]
112
+ const newValue = newRecord[key] as T[keyof T]
113
+
114
+ if (!isEqual(oldValue, newValue, visited))
115
+ change[key as keyof T] = newValue
116
+ }
117
+
118
+ const changed =
119
+ Object.keys(add).length > 0 ||
120
+ Object.keys(change).length > 0 ||
121
+ Object.keys(remove).length > 0
122
+
123
+ return {
124
+ changed,
125
+ add,
126
+ change,
127
+ remove,
128
+ }
129
+ }
130
+
131
+ return diffRecords(oldObj, newObj)
132
+ }
133
+
134
+ /* === Exports === */
135
+
136
+ export { type DiffResult, diff, isEqual, type UnknownRecord }
package/src/effect.ts CHANGED
@@ -1,78 +1,88 @@
1
- import { type Signal, type SignalValues, UNSET } from './signal'
2
- import { CircularDependencyError, isFunction, toError } from './util'
3
- import { type Cleanup, watch, observe } from './scheduler'
1
+ import { type Cleanup, observe, watch } from './scheduler'
2
+ import {
3
+ CircularDependencyError,
4
+ isAbortError,
5
+ isAsyncFunction,
6
+ isFunction,
7
+ } from './util'
4
8
 
5
9
  /* === Types === */
6
10
 
7
- type EffectMatcher<S extends Signal<{}>[]> = {
8
- signals: S
9
- ok: (...values: SignalValues<S>) => void | Cleanup
10
- err?: (...errors: Error[]) => void | Cleanup
11
- nil?: () => void | Cleanup
12
- }
11
+ // biome-ignore lint/suspicious/noConfusingVoidType: optional Cleanup return type
12
+ type MaybeCleanup = Cleanup | undefined | void
13
+
14
+ type EffectCallback =
15
+ | (() => MaybeCleanup)
16
+ | ((abort: AbortSignal) => Promise<MaybeCleanup>)
13
17
 
14
18
  /* === Functions === */
15
19
 
16
20
  /**
17
21
  * Define what happens when a reactive state changes
18
22
  *
23
+ * The callback can be synchronous or asynchronous. Async callbacks receive
24
+ * an AbortSignal parameter, which is automatically aborted when the effect
25
+ * re-runs or is cleaned up, preventing stale async operations.
26
+ *
19
27
  * @since 0.1.0
20
- * @param {EffectMatcher<S> | (() => void | Cleanup)} matcher - effect matcher or callback
21
- * @returns {Cleanup} - cleanup function for the effect
28
+ * @param {EffectCallback} callback - Synchronous or asynchronous effect callback
29
+ * @returns {Cleanup} - Cleanup function for the effect
22
30
  */
23
- function effect<S extends Signal<{}>[]>(
24
- matcher: EffectMatcher<S> | (() => void | Cleanup),
25
- ): Cleanup {
26
- const {
27
- signals,
28
- ok,
29
- err = console.error,
30
- nil = () => {},
31
- } = isFunction(matcher)
32
- ? { signals: [] as unknown as S, ok: matcher }
33
- : matcher
34
-
31
+ const effect = (callback: EffectCallback): Cleanup => {
32
+ const isAsync = isAsyncFunction<MaybeCleanup>(callback)
35
33
  let running = false
34
+ let controller: AbortController | undefined
35
+
36
36
  const run = watch(() =>
37
37
  observe(() => {
38
38
  if (running) throw new CircularDependencyError('effect')
39
39
  running = true
40
40
 
41
- // Pure part
42
- const errors: Error[] = []
43
- let pending = false
44
- const values = signals.map(signal => {
45
- try {
46
- const value = signal.get()
47
- if (value === UNSET) pending = true
48
- return value
49
- } catch (e) {
50
- errors.push(toError(e))
51
- return UNSET
52
- }
53
- }) as SignalValues<S>
41
+ // Abort any previous async operations
42
+ controller?.abort()
43
+ controller = undefined
44
+
45
+ let cleanup: MaybeCleanup | Promise<MaybeCleanup>
54
46
 
55
- // Effectful part
56
- let cleanup: void | Cleanup = undefined
57
47
  try {
58
- cleanup = pending
59
- ? nil()
60
- : errors.length
61
- ? err(...errors)
62
- : ok(...values)
63
- } catch (e) {
64
- cleanup = err(toError(e))
65
- } finally {
66
- if (isFunction(cleanup)) run.off(cleanup)
48
+ if (isAsync) {
49
+ // Create AbortController for async callback
50
+ controller = new AbortController()
51
+ const currentController = controller
52
+ callback(controller.signal)
53
+ .then(cleanup => {
54
+ // Only register cleanup if this is still the current controller
55
+ if (
56
+ isFunction(cleanup) &&
57
+ controller === currentController
58
+ ) {
59
+ run.off(cleanup)
60
+ }
61
+ })
62
+ .catch(error => {
63
+ if (!isAbortError(error))
64
+ console.error('Async effect error:', error)
65
+ })
66
+ } else {
67
+ cleanup = (callback as () => MaybeCleanup)()
68
+ if (isFunction(cleanup)) run.off(cleanup)
69
+ }
70
+ } catch (error) {
71
+ if (!isAbortError(error))
72
+ console.error('Effect callback error:', error)
67
73
  }
68
74
 
69
75
  running = false
70
76
  }, run),
71
77
  )
78
+
72
79
  run()
73
- return () => run.cleanup()
80
+ return () => {
81
+ controller?.abort()
82
+ run.cleanup()
83
+ }
74
84
  }
75
85
 
76
86
  /* === Exports === */
77
87
 
78
- export { type EffectMatcher, effect }
88
+ export { type MaybeCleanup, type EffectCallback, effect }
package/src/match.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { ResolveResult } from './resolve'
2
+ import type { Signal, SignalValues } from './signal'
3
+ import { toError } from './util'
4
+
5
+ /* === Types === */
6
+
7
+ type MatchHandlers<S extends Record<string, Signal<unknown & {}>>> = {
8
+ ok?: (values: SignalValues<S>) => void
9
+ err?: (errors: readonly Error[]) => void
10
+ nil?: () => void
11
+ }
12
+
13
+ /* === Functions === */
14
+
15
+ /**
16
+ * Match on resolve result and call appropriate handler for side effects
17
+ *
18
+ * This is a utility function for those who prefer the handler pattern.
19
+ * All handlers are for side effects only and return void. If you need
20
+ * cleanup logic, use a hoisted let variable in your effect.
21
+ *
22
+ * @since 0.15.0
23
+ * @param {ResolveResult<S>} result - Result from resolve()
24
+ * @param {MatchHandlers<S>} handlers - Handlers for different states (side effects only)
25
+ * @returns {void} - Always returns void
26
+ */
27
+ function match<S extends Record<string, Signal<unknown & {}>>>(
28
+ result: ResolveResult<S>,
29
+ handlers: MatchHandlers<S>,
30
+ ): void {
31
+ try {
32
+ if (result.pending) {
33
+ handlers.nil?.()
34
+ } else if (result.errors) {
35
+ handlers.err?.(result.errors)
36
+ } else {
37
+ handlers.ok?.(result.values as SignalValues<S>)
38
+ }
39
+ } catch (error) {
40
+ // If handler throws, try error handler, otherwise rethrow
41
+ if (
42
+ handlers.err &&
43
+ (!result.errors || !result.errors.includes(toError(error)))
44
+ ) {
45
+ const allErrors = result.errors
46
+ ? [...result.errors, toError(error)]
47
+ : [toError(error)]
48
+ handlers.err(allErrors)
49
+ } else {
50
+ throw error
51
+ }
52
+ }
53
+ }
54
+
55
+ /* === Exports === */
56
+
57
+ export { match, type MatchHandlers }
package/src/resolve.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { UnknownRecord } from './diff'
2
+ import { type Signal, type SignalValues, UNSET } from './signal'
3
+ import { toError } from './util'
4
+
5
+ /* === Types === */
6
+
7
+ type ResolveResult<S extends Record<string, Signal<unknown & {}>>> =
8
+ | { ok: true; values: SignalValues<S>; errors?: never; pending?: never }
9
+ | { ok: false; errors: readonly Error[]; values?: never; pending?: never }
10
+ | { ok: false; pending: true; values?: never; errors?: never }
11
+
12
+ /* === Functions === */
13
+
14
+ /**
15
+ * Resolve signal values with perfect type inference
16
+ *
17
+ * Always returns a discriminated union result, regardless of whether
18
+ * handlers are provided or not. This ensures a predictable API.
19
+ *
20
+ * @since 0.15.0
21
+ * @param {S} signals - Signals to resolve
22
+ * @returns {ResolveResult<S>} - Discriminated union result
23
+ */
24
+ function resolve<S extends Record<string, Signal<unknown & {}>>>(
25
+ signals: S,
26
+ ): ResolveResult<S> {
27
+ const errors: Error[] = []
28
+ let pending = false
29
+ const values: UnknownRecord = {}
30
+
31
+ // Collect values and errors
32
+ for (const [key, signal] of Object.entries(signals)) {
33
+ try {
34
+ const value = signal.get()
35
+
36
+ if (value === UNSET) {
37
+ pending = true
38
+ } else {
39
+ values[key] = value
40
+ }
41
+ } catch (e) {
42
+ errors.push(toError(e))
43
+ }
44
+ }
45
+
46
+ // Return discriminated union
47
+ if (pending) {
48
+ return { ok: false, pending: true }
49
+ }
50
+ if (errors.length > 0) {
51
+ return { ok: false, errors }
52
+ }
53
+ return { ok: true, values: values as SignalValues<S> }
54
+ }
55
+
56
+ /* === Exports === */
57
+
58
+ export { resolve, type ResolveResult }
package/src/scheduler.ts CHANGED
@@ -8,7 +8,7 @@ type Watcher = {
8
8
  cleanup(): void
9
9
  }
10
10
 
11
- type Updater = <T>() => T | boolean | void
11
+ type Updater = <T>() => T | boolean | undefined
12
12
 
13
13
  /* === Internal === */
14
14
 
@@ -145,8 +145,8 @@ const observe = (run: () => void, watcher?: Watcher): void => {
145
145
  * @param {symbol} dedupe - Symbol for deduplication; if not provided, a unique Symbol is created ensuring the update is always executed
146
146
  */
147
147
  const enqueue = <T>(fn: Updater, dedupe?: symbol) =>
148
- new Promise<T | boolean | void>((resolve, reject) => {
149
- updateMap.set(dedupe || Symbol(), () => {
148
+ new Promise<T | boolean | undefined>((resolve, reject) => {
149
+ updateMap.set(dedupe || Symbol(), (): undefined => {
150
150
  try {
151
151
  resolve(fn())
152
152
  } catch (error) {