@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.
@@ -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
- const v = new ObservableValue(undefined)
10
- expect(v).toBeInstanceOf(ObservableValue)
11
- expect(v.getValue()).toBe(undefined)
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
- const v = new ObservableValue(1)
16
- expect(v.getValue()).toBe(1)
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
- const v = new ObservableValue(1)
22
- const doneCallback = vi.fn()
25
+ using(new ObservableValue(1), (v) => {
26
+ const doneCallback = vi.fn()
23
27
 
24
- v.subscribe(() => {
25
- expect(v.getValue()).toBe(2)
26
- doneCallback()
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
- const v = new ObservableValue(1)
36
- const doneCallback = vi.fn()
40
+ using(new ObservableValue(1), (v) => {
41
+ const doneCallback = vi.fn()
37
42
 
38
- v.subscribe((value) => {
39
- expect(value).toBe(2)
40
- doneCallback()
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
- const v = new ObservableValue(1)
48
- const doneCallback = vi.fn()
53
+ using(new ObservableValue(1), (v) => {
54
+ const doneCallback = vi.fn()
49
55
 
50
- v.subscribe(async (value) => {
51
- expect(value).toBe(2)
52
- doneCallback()
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
- const v = new ObservableValue(1)
68
- const observer1 = v.subscribe(shouldNotCall)
69
- v.subscribe(doneCallback)
74
+ using(new ObservableValue(1), (v) => {
75
+ const observer1 = v.subscribe(shouldNotCall)
76
+ v.subscribe(doneCallback)
70
77
 
71
- v.unsubscribe(observer1)
72
- v.setValue(2)
78
+ v.unsubscribe(observer1)
79
+ v.setValue(2)
73
80
 
74
- expect(doneCallback).toBeCalledTimes(1)
75
- expect(shouldNotCall).not.toBeCalled()
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
- const callback1 = () => {
97
- /** */
98
- }
99
- const v = new ObservableValue(1)
100
- const observer = v.subscribe(callback1)
101
- expect(v.getObservers().length).toBe(1)
102
- observer[Symbol.dispose]()
103
- expect(v.getObservers().length).toBe(0)
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
- const v = new ObservableValue(1)
137
- const observer = v.subscribe(() => new Alma().Callback())
138
- v.subscribe(() => new Alma().Callback())
139
- expect(v.getObservers().length).toBe(2)
140
- observer[Symbol.dispose]()
141
- expect(v.getObservers().length).toBe(1)
142
- v.setValue(3)
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
- expect(doneCallback).toBeCalledTimes(1)
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
- const v = new ObservableValue(
151
- { value: 2 },
152
- {
153
- compare: (a, b) => a.value !== b.value,
154
- },
155
- )
156
- const onChange = vi.fn()
157
- v.subscribe(onChange)
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
- v.setValue({ value: 2 })
160
- expect(v.getValue()).toEqual({ value: 2 })
161
- expect(onChange).not.toBeCalled()
171
+ v.setValue({ value: 2 })
172
+ expect(v.getValue()).toEqual({ value: 2 })
173
+ expect(onChange).not.toBeCalled()
162
174
 
163
- v.setValue({ value: 3 })
164
- expect(v.getValue()).toEqual({ value: 3 })
165
- expect(onChange).toBeCalledTimes(1)
166
- expect(onChange).toBeCalledWith({ value: 3 })
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
- v.setValue({ value: 3 })
169
- expect(v.getValue()).toEqual({ value: 3 })
170
- expect(onChange).toBeCalledTimes(1)
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
- const v = new ObservableValue({ shouldNotify: true, value: 1 })
177
- const onChange = vi.fn()
178
- v.subscribe(onChange, {
179
- filter: (nextValue) => nextValue.shouldNotify,
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
- v.setValue({ shouldNotify: false, value: 1 })
183
- expect(onChange).not.toBeCalled()
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
- v.setValue({ shouldNotify: false, value: 2 })
186
- expect(onChange).not.toBeCalled()
187
- expect(v.getValue()).toEqual({ shouldNotify: false, value: 2 })
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
- v.setValue({ shouldNotify: true, value: 3 })
190
- expect(onChange).toBeCalledTimes(1)
191
- expect(onChange).toBeCalledWith({ shouldNotify: true, value: 3 })
307
+ consoleErrorSpy.mockRestore()
192
308
  })
193
309
  })
194
310
  })
@@ -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
- if (observer.options?.filter?.(this.currentValue, newValue) !== false) {
116
- observer.callback(newValue)
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