@furystack/utils 8.1.9 → 8.2.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/CHANGELOG.md +66 -0
- package/esm/event-hub.d.ts +21 -0
- package/esm/event-hub.d.ts.map +1 -1
- package/esm/event-hub.js +48 -1
- package/esm/event-hub.js.map +1 -1
- package/esm/event-hub.spec.js +112 -0
- package/esm/event-hub.spec.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/observable-value.d.ts +9 -0
- package/esm/observable-value.d.ts.map +1 -1
- package/esm/observable-value.js +13 -2
- package/esm/observable-value.js.map +1 -1
- package/esm/observable-value.spec.d.ts.map +1 -1
- package/esm/observable-value.spec.js +160 -76
- package/esm/observable-value.spec.js.map +1 -1
- package/esm/semaphore.d.ts +104 -0
- package/esm/semaphore.d.ts.map +1 -0
- package/esm/semaphore.js +185 -0
- package/esm/semaphore.js.map +1 -0
- package/esm/semaphore.spec.d.ts +2 -0
- package/esm/semaphore.spec.d.ts.map +1 -0
- package/esm/semaphore.spec.js +356 -0
- package/esm/semaphore.spec.js.map +1 -0
- package/package.json +2 -2
- package/src/event-hub.spec.ts +153 -0
- package/src/event-hub.ts +57 -1
- package/src/index.ts +1 -0
- package/src/observable-value.spec.ts +197 -81
- package/src/observable-value.ts +18 -2
- package/src/semaphore.spec.ts +467 -0
- package/src/semaphore.ts +237 -0
|
@@ -1,58 +1,65 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { ObservableValue } from './observable-value.js'
|
|
3
|
+
import { sleepAsync } from './sleep-async.js'
|
|
4
|
+
import { using } from './using.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Observable Value tests
|
|
6
8
|
*/
|
|
7
9
|
export const observableTests = describe('Observable', () => {
|
|
8
10
|
it('should be constructed with an undefined initial value', () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
using(new ObservableValue(undefined), (v) => {
|
|
12
|
+
expect(v).toBeInstanceOf(ObservableValue)
|
|
13
|
+
expect(v.getValue()).toBe(undefined)
|
|
14
|
+
})
|
|
12
15
|
})
|
|
13
16
|
|
|
14
17
|
it('should be constructed with initial value', () => {
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
using(new ObservableValue(1), (v) => {
|
|
19
|
+
expect(v.getValue()).toBe(1)
|
|
20
|
+
})
|
|
17
21
|
})
|
|
18
22
|
|
|
19
23
|
describe('Subscription callback', () => {
|
|
20
24
|
it('should be triggered only when a value is changed', () => {
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
using(new ObservableValue(1), (v) => {
|
|
26
|
+
const doneCallback = vi.fn()
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
v.subscribe(() => {
|
|
29
|
+
expect(v.getValue()).toBe(2)
|
|
30
|
+
doneCallback()
|
|
31
|
+
})
|
|
32
|
+
v.setValue(1)
|
|
33
|
+
v.setValue(1)
|
|
34
|
+
v.setValue(2)
|
|
35
|
+
expect(doneCallback).toBeCalledTimes(1)
|
|
27
36
|
})
|
|
28
|
-
v.setValue(1)
|
|
29
|
-
v.setValue(1)
|
|
30
|
-
v.setValue(2)
|
|
31
|
-
expect(doneCallback).toBeCalledTimes(1)
|
|
32
37
|
})
|
|
33
38
|
|
|
34
39
|
it('should be triggered only on change', () => {
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
using(new ObservableValue(1), (v) => {
|
|
41
|
+
const doneCallback = vi.fn()
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
v.subscribe((value) => {
|
|
44
|
+
expect(value).toBe(2)
|
|
45
|
+
doneCallback()
|
|
46
|
+
})
|
|
47
|
+
v.setValue(2)
|
|
48
|
+
expect(doneCallback).toBeCalledTimes(1)
|
|
41
49
|
})
|
|
42
|
-
v.setValue(2)
|
|
43
|
-
expect(doneCallback).toBeCalledTimes(1)
|
|
44
50
|
})
|
|
45
51
|
|
|
46
52
|
it('should be triggered only on change in async manner', () => {
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
using(new ObservableValue(1), (v) => {
|
|
54
|
+
const doneCallback = vi.fn()
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
v.subscribe(async (value) => {
|
|
57
|
+
expect(value).toBe(2)
|
|
58
|
+
doneCallback()
|
|
59
|
+
})
|
|
60
|
+
v.setValue(2)
|
|
61
|
+
expect(doneCallback).toBeCalledTimes(1)
|
|
53
62
|
})
|
|
54
|
-
v.setValue(2)
|
|
55
|
-
expect(doneCallback).toBeCalledTimes(1)
|
|
56
63
|
})
|
|
57
64
|
})
|
|
58
65
|
|
|
@@ -64,15 +71,16 @@ export const observableTests = describe('Observable', () => {
|
|
|
64
71
|
expect(value).toBe(2)
|
|
65
72
|
})
|
|
66
73
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
using(new ObservableValue(1), (v) => {
|
|
75
|
+
const observer1 = v.subscribe(shouldNotCall)
|
|
76
|
+
v.subscribe(doneCallback)
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
v.unsubscribe(observer1)
|
|
79
|
+
v.setValue(2)
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
expect(doneCallback).toBeCalledTimes(1)
|
|
82
|
+
expect(shouldNotCall).not.toBeCalled()
|
|
83
|
+
})
|
|
76
84
|
})
|
|
77
85
|
|
|
78
86
|
it('should remove the subscription on Observable dispose', () => {
|
|
@@ -93,14 +101,15 @@ export const observableTests = describe('Observable', () => {
|
|
|
93
101
|
})
|
|
94
102
|
|
|
95
103
|
it('should remove the subscription on Observer dispose', () => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
using(new ObservableValue(1), (v) => {
|
|
105
|
+
const callback1 = () => {
|
|
106
|
+
/** */
|
|
107
|
+
}
|
|
108
|
+
const observer = v.subscribe(callback1)
|
|
109
|
+
expect(v.getObservers().length).toBe(1)
|
|
110
|
+
observer[Symbol.dispose]()
|
|
111
|
+
expect(v.getObservers().length).toBe(0)
|
|
112
|
+
})
|
|
104
113
|
})
|
|
105
114
|
|
|
106
115
|
it('should throw an error for setValue() when the observer has been disposed', () => {
|
|
@@ -133,62 +142,169 @@ export const observableTests = describe('Observable', () => {
|
|
|
133
142
|
doneCallback()
|
|
134
143
|
}
|
|
135
144
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
using(new ObservableValue(1), (v) => {
|
|
146
|
+
const observer = v.subscribe(() => new Alma().Callback())
|
|
147
|
+
v.subscribe(() => new Alma().Callback())
|
|
148
|
+
expect(v.getObservers().length).toBe(2)
|
|
149
|
+
observer[Symbol.dispose]()
|
|
150
|
+
expect(v.getObservers().length).toBe(1)
|
|
151
|
+
v.setValue(3)
|
|
143
152
|
|
|
144
|
-
|
|
153
|
+
expect(doneCallback).toBeCalledTimes(1)
|
|
154
|
+
})
|
|
145
155
|
})
|
|
146
156
|
})
|
|
147
157
|
|
|
148
158
|
describe('Custom Compare function', () => {
|
|
149
159
|
it('Should compare the values with the custom compare function', () => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
using(
|
|
161
|
+
new ObservableValue(
|
|
162
|
+
{ value: 2 },
|
|
163
|
+
{
|
|
164
|
+
compare: (a, b) => a.value !== b.value,
|
|
165
|
+
},
|
|
166
|
+
),
|
|
167
|
+
(v) => {
|
|
168
|
+
const onChange = vi.fn()
|
|
169
|
+
v.subscribe(onChange)
|
|
158
170
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
v.setValue({ value: 2 })
|
|
172
|
+
expect(v.getValue()).toEqual({ value: 2 })
|
|
173
|
+
expect(onChange).not.toBeCalled()
|
|
162
174
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
v.setValue({ value: 3 })
|
|
176
|
+
expect(v.getValue()).toEqual({ value: 3 })
|
|
177
|
+
expect(onChange).toBeCalledTimes(1)
|
|
178
|
+
expect(onChange).toBeCalledWith({ value: 3 })
|
|
167
179
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
180
|
+
v.setValue({ value: 3 })
|
|
181
|
+
expect(v.getValue()).toEqual({ value: 3 })
|
|
182
|
+
expect(onChange).toBeCalledTimes(1)
|
|
183
|
+
},
|
|
184
|
+
)
|
|
171
185
|
})
|
|
172
186
|
})
|
|
173
187
|
|
|
174
188
|
describe('Filtered subscriptions', () => {
|
|
175
189
|
it('should not trigger the callback if the filter returns false', () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
190
|
+
using(new ObservableValue({ shouldNotify: true, value: 1 }), (v) => {
|
|
191
|
+
const onChange = vi.fn()
|
|
192
|
+
v.subscribe(onChange, {
|
|
193
|
+
filter: (nextValue) => nextValue.shouldNotify,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
v.setValue({ shouldNotify: false, value: 1 })
|
|
197
|
+
expect(onChange).not.toBeCalled()
|
|
198
|
+
|
|
199
|
+
v.setValue({ shouldNotify: false, value: 2 })
|
|
200
|
+
expect(onChange).not.toBeCalled()
|
|
201
|
+
expect(v.getValue()).toEqual({ shouldNotify: false, value: 2 })
|
|
202
|
+
|
|
203
|
+
v.setValue({ shouldNotify: true, value: 3 })
|
|
204
|
+
expect(onChange).toBeCalledTimes(1)
|
|
205
|
+
expect(onChange).toBeCalledWith({ shouldNotify: true, value: 3 })
|
|
180
206
|
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
181
209
|
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
describe('Observer error handling', () => {
|
|
211
|
+
it('should catch sync throws from observer callbacks and still notify other observers', () => {
|
|
212
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
184
213
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
214
|
+
using(new ObservableValue(0), (v) => {
|
|
215
|
+
const goodCallback = vi.fn()
|
|
216
|
+
|
|
217
|
+
v.subscribe(() => {
|
|
218
|
+
throw new Error('observer error')
|
|
219
|
+
})
|
|
220
|
+
v.subscribe(goodCallback)
|
|
221
|
+
|
|
222
|
+
v.setValue(1)
|
|
223
|
+
|
|
224
|
+
expect(goodCallback).toBeCalledWith(1)
|
|
225
|
+
expect(goodCallback).toBeCalledTimes(1)
|
|
226
|
+
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
consoleErrorSpy.mockRestore()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should catch async rejections from observer callbacks', async () => {
|
|
233
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
234
|
+
|
|
235
|
+
using(new ObservableValue(0), (v) => {
|
|
236
|
+
v.subscribe(async () => {
|
|
237
|
+
throw new Error('async observer error')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
v.setValue(1)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await sleepAsync(10)
|
|
244
|
+
|
|
245
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('Error in ObservableValue observer', expect.any(Error))
|
|
246
|
+
|
|
247
|
+
consoleErrorSpy.mockRestore()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should catch errors thrown by filter functions', () => {
|
|
251
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
252
|
+
|
|
253
|
+
using(new ObservableValue(0), (v) => {
|
|
254
|
+
const goodCallback = vi.fn()
|
|
255
|
+
|
|
256
|
+
v.subscribe(
|
|
257
|
+
() => {
|
|
258
|
+
/* never reached */
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
filter: () => {
|
|
262
|
+
throw new Error('filter error')
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
v.subscribe(goodCallback)
|
|
267
|
+
|
|
268
|
+
v.setValue(1)
|
|
269
|
+
|
|
270
|
+
expect(goodCallback).toBeCalledWith(1)
|
|
271
|
+
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
consoleErrorSpy.mockRestore()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should use custom onError callback when provided', () => {
|
|
278
|
+
const onError = vi.fn()
|
|
279
|
+
|
|
280
|
+
using(new ObservableValue(0, { onError }), (v) => {
|
|
281
|
+
v.subscribe(() => {
|
|
282
|
+
throw new Error('custom error')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
v.setValue(1)
|
|
286
|
+
|
|
287
|
+
expect(onError).toBeCalledTimes(1)
|
|
288
|
+
expect(onError).toBeCalledWith({
|
|
289
|
+
error: expect.any(Error) as Error,
|
|
290
|
+
observer: expect.objectContaining({ callback: expect.any(Function) as () => void }) as object,
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should update the value even when observers throw', () => {
|
|
296
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
297
|
+
|
|
298
|
+
using(new ObservableValue(0), (v) => {
|
|
299
|
+
v.subscribe(() => {
|
|
300
|
+
throw new Error('error')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
v.setValue(42)
|
|
304
|
+
expect(v.getValue()).toBe(42)
|
|
305
|
+
})
|
|
188
306
|
|
|
189
|
-
|
|
190
|
-
expect(onChange).toBeCalledTimes(1)
|
|
191
|
-
expect(onChange).toBeCalledWith({ shouldNotify: true, value: 3 })
|
|
307
|
+
consoleErrorSpy.mockRestore()
|
|
192
308
|
})
|
|
193
309
|
})
|
|
194
310
|
})
|
package/src/observable-value.ts
CHANGED
|
@@ -23,6 +23,12 @@ export type ObservableValueOptions<T> = {
|
|
|
23
23
|
* @returns whether the value should be updated and the observers should be notified
|
|
24
24
|
*/
|
|
25
25
|
compare: (lastValue: T, nextValue: T) => boolean
|
|
26
|
+
/**
|
|
27
|
+
* Called when an observer callback or filter throws (sync) or rejects (async).
|
|
28
|
+
* All remaining observers are still notified even when one fails.
|
|
29
|
+
* Defaults to logging the error via `console.error`.
|
|
30
|
+
*/
|
|
31
|
+
onError: (options: { error: unknown; observer: ValueObserver<T> }) => void
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
const defaultComparer = <T>(a: T, b: T) => a !== b
|
|
@@ -112,8 +118,17 @@ export class ObservableValue<T> implements Disposable {
|
|
|
112
118
|
if (this.options.compare(this.currentValue, newValue)) {
|
|
113
119
|
this.currentValue = newValue
|
|
114
120
|
this.observers.forEach((observer) => {
|
|
115
|
-
|
|
116
|
-
observer.
|
|
121
|
+
try {
|
|
122
|
+
if (observer.options?.filter?.(this.currentValue, newValue) !== false) {
|
|
123
|
+
const result = observer.callback(newValue)
|
|
124
|
+
if (result && typeof result.then === 'function') {
|
|
125
|
+
result.then(undefined, (error: unknown) => {
|
|
126
|
+
this.options.onError({ error, observer })
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.options.onError({ error, observer })
|
|
117
132
|
}
|
|
118
133
|
})
|
|
119
134
|
}
|
|
@@ -136,6 +151,7 @@ export class ObservableValue<T> implements Disposable {
|
|
|
136
151
|
constructor(initialValue: T, options?: Partial<ObservableValueOptions<T>>) {
|
|
137
152
|
this.options = {
|
|
138
153
|
compare: defaultComparer,
|
|
154
|
+
onError: ({ error }) => console.error('Error in ObservableValue observer', error),
|
|
139
155
|
...options,
|
|
140
156
|
}
|
|
141
157
|
this.currentValue = initialValue
|