@meistrari/tela-build 1.0.2 → 1.1.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/components/tela/status/status.vue +1 -1
- package/composables/__tests__/optimistic-async-data.test.ts +606 -0
- package/composables/optimistic-async-data-demo.vue +405 -0
- package/composables/optimistic-async-data-overview.vue +373 -0
- package/composables/optimistic-async-data-shared-state-demo.vue +498 -0
- package/composables/optimistic-async-data.stories.ts +61 -0
- package/composables/optimistic-async-data.ts +264 -0
- package/nuxt.config.ts +7 -0
- package/package.json +1 -1
|
@@ -224,7 +224,7 @@ const variantConfig: Record<TelaStatusVariant, StatusConfig> = {
|
|
|
224
224
|
},
|
|
225
225
|
'failed': {
|
|
226
226
|
label: 'Failed',
|
|
227
|
-
backgroundColor: 'bg-red-
|
|
227
|
+
backgroundColor: 'bg-red-100',
|
|
228
228
|
textColor: 'text-red-700',
|
|
229
229
|
iconName: 'close',
|
|
230
230
|
iconColor: 'red-500',
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createApp, nextTick, watch, watchEffect } from 'vue'
|
|
3
|
+
import type { App } from 'vue'
|
|
4
|
+
import { useOptimisticAsyncData } from '../optimistic-async-data'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper function to test composables that use provide/inject
|
|
8
|
+
* Creates a Vue app context for the composable
|
|
9
|
+
*/
|
|
10
|
+
function withSetup<T>(composable: () => T): [T, App] {
|
|
11
|
+
let result!: T
|
|
12
|
+
const app = createApp({
|
|
13
|
+
setup() {
|
|
14
|
+
result = composable()
|
|
15
|
+
// Return a render function to suppress warnings
|
|
16
|
+
return () => {}
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
app.mount(document.createElement('div'))
|
|
20
|
+
return [result, app]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('useOptimisticAsyncData', () => {
|
|
24
|
+
describe('basic functionality', () => {
|
|
25
|
+
it('should initialize with idle state', () => {
|
|
26
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData({ key: 'test-1' }))
|
|
27
|
+
|
|
28
|
+
expect(asyncData.state.value).toBe('idle')
|
|
29
|
+
expect(asyncData.data.value).toBe(null)
|
|
30
|
+
expect(asyncData.error.value).toBe(null)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should generate unique key when not provided', () => {
|
|
34
|
+
const [asyncData1] = withSetup(() => useOptimisticAsyncData())
|
|
35
|
+
const [asyncData2] = withSetup(() => useOptimisticAsyncData())
|
|
36
|
+
|
|
37
|
+
expect(asyncData1.key).toBeDefined()
|
|
38
|
+
expect(asyncData2.key).toBeDefined()
|
|
39
|
+
expect(asyncData1.key).not.toBe(asyncData2.key)
|
|
40
|
+
expect(asyncData1.key).toMatch(/^async-data-/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should use provided custom key', () => {
|
|
44
|
+
const customKey = 'my-custom-key'
|
|
45
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData({ key: customKey }))
|
|
46
|
+
|
|
47
|
+
expect(asyncData.key).toBe(customKey)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('state transitions - success flow', () => {
|
|
52
|
+
it('should transition from idle → pending → success', async () => {
|
|
53
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-2' }))
|
|
54
|
+
|
|
55
|
+
const states: string[] = []
|
|
56
|
+
watchEffect(() => {
|
|
57
|
+
states.push(asyncData.state.value)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const promise = asyncData.promise(async () => {
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
62
|
+
return 'test-data'
|
|
63
|
+
})()
|
|
64
|
+
|
|
65
|
+
await nextTick()
|
|
66
|
+
await promise
|
|
67
|
+
|
|
68
|
+
expect(states).toEqual(['idle', 'pending', 'success'])
|
|
69
|
+
expect(asyncData.state.value).toBe('success')
|
|
70
|
+
expect(asyncData.data.value).toBe('test-data')
|
|
71
|
+
expect(asyncData.error.value).toBe(null)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should return data on successful promise', async () => {
|
|
75
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<{ id: number }>({ key: 'test-3' }))
|
|
76
|
+
|
|
77
|
+
const result = await asyncData.promise(async () => ({ id: 123 }))()
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({ id: 123 })
|
|
80
|
+
expect(asyncData.data.value).toEqual({ id: 123 })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should update data on subsequent successful operations', async () => {
|
|
84
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<number>({ key: 'test-4' }))
|
|
85
|
+
|
|
86
|
+
await asyncData.promise(async () => 1)()
|
|
87
|
+
expect(asyncData.data.value).toBe(1)
|
|
88
|
+
|
|
89
|
+
await asyncData.promise(async () => 2)()
|
|
90
|
+
expect(asyncData.data.value).toBe(2)
|
|
91
|
+
|
|
92
|
+
await asyncData.promise(async () => 3)()
|
|
93
|
+
expect(asyncData.data.value).toBe(3)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should pass arguments to the async function correctly', async () => {
|
|
97
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-args' }))
|
|
98
|
+
|
|
99
|
+
const result = await asyncData.promise(async (a: number, b: string, c: boolean) => {
|
|
100
|
+
return `${a}-${b}-${c}`
|
|
101
|
+
})(42, 'hello', true)
|
|
102
|
+
|
|
103
|
+
expect(result).toBe('42-hello-true')
|
|
104
|
+
expect(asyncData.data.value).toBe('42-hello-true')
|
|
105
|
+
expect(asyncData.state.value).toBe('success')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should pass object arguments correctly', async () => {
|
|
109
|
+
interface Input { id: number, name: string }
|
|
110
|
+
interface Output { processed: boolean, input: Input }
|
|
111
|
+
|
|
112
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<Output>({ key: 'test-obj-args' }))
|
|
113
|
+
|
|
114
|
+
const input: Input = { id: 1, name: 'test' }
|
|
115
|
+
const result = await asyncData.promise(async (data: Input) => {
|
|
116
|
+
return { processed: true, input: data }
|
|
117
|
+
})(input)
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({ processed: true, input: { id: 1, name: 'test' } })
|
|
120
|
+
expect(asyncData.data.value).toEqual({ processed: true, input: { id: 1, name: 'test' } })
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('state transitions - error flow', () => {
|
|
125
|
+
it('should transition from idle → pending → error', async () => {
|
|
126
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData({ key: 'test-5' }))
|
|
127
|
+
|
|
128
|
+
const states: string[] = []
|
|
129
|
+
watchEffect(() => {
|
|
130
|
+
states.push(asyncData.state.value)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const promise = asyncData.promise(async () => {
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
135
|
+
throw new Error('Test error')
|
|
136
|
+
})()
|
|
137
|
+
|
|
138
|
+
await nextTick()
|
|
139
|
+
await expect(promise).rejects.toThrow('Test error')
|
|
140
|
+
|
|
141
|
+
expect(states).toEqual(['idle', 'pending', 'error'])
|
|
142
|
+
expect(asyncData.state.value).toBe('error')
|
|
143
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
144
|
+
expect(asyncData.error.value?.message).toBe('Test error')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should throw error on failed promise', async () => {
|
|
148
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData({ key: 'test-6' }))
|
|
149
|
+
|
|
150
|
+
await expect(asyncData.promise(async () => {
|
|
151
|
+
throw new Error('Test error')
|
|
152
|
+
})()).rejects.toThrow('Test error')
|
|
153
|
+
|
|
154
|
+
expect(asyncData.state.value).toBe('error')
|
|
155
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
156
|
+
expect(asyncData.error.value?.message).toBe('Test error')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('optimistic updates and rollback', () => {
|
|
161
|
+
it('should preserve previous data during pending state', async () => {
|
|
162
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-9' }))
|
|
163
|
+
|
|
164
|
+
// Set initial data
|
|
165
|
+
await asyncData.promise(async () => 'initial-data')()
|
|
166
|
+
expect(asyncData.data.value).toBe('initial-data')
|
|
167
|
+
|
|
168
|
+
// Start new operation
|
|
169
|
+
const promise = asyncData.promise(async () => {
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
171
|
+
return 'new-data'
|
|
172
|
+
})()
|
|
173
|
+
|
|
174
|
+
// During pending, data should still be available
|
|
175
|
+
await nextTick()
|
|
176
|
+
expect(asyncData.state.value).toBe('pending')
|
|
177
|
+
expect(asyncData.data.value).toBe('initial-data')
|
|
178
|
+
|
|
179
|
+
await promise
|
|
180
|
+
expect(asyncData.data.value).toBe('new-data')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('should rollback to previous data on error', async () => {
|
|
184
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-10' }))
|
|
185
|
+
|
|
186
|
+
// Set initial data
|
|
187
|
+
await asyncData.promise(async () => 'initial-data')()
|
|
188
|
+
expect(asyncData.data.value).toBe('initial-data')
|
|
189
|
+
|
|
190
|
+
// Fail operation
|
|
191
|
+
await expect(asyncData.promise(async () => {
|
|
192
|
+
throw new Error('Operation failed')
|
|
193
|
+
})()).rejects.toThrow('Operation failed')
|
|
194
|
+
|
|
195
|
+
// Data should be rolled back
|
|
196
|
+
expect(asyncData.state.value).toBe('error')
|
|
197
|
+
expect(asyncData.data.value).toBe('initial-data')
|
|
198
|
+
expect(asyncData.error.value?.message).toBe('Operation failed')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should rollback to null when no previous data exists', async () => {
|
|
202
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-11' }))
|
|
203
|
+
|
|
204
|
+
await expect(asyncData.promise(async () => {
|
|
205
|
+
throw new Error('First operation failed')
|
|
206
|
+
})()).rejects.toThrow('First operation failed')
|
|
207
|
+
|
|
208
|
+
expect(asyncData.state.value).toBe('error')
|
|
209
|
+
expect(asyncData.data.value).toBe(null)
|
|
210
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
211
|
+
expect(asyncData.error.value?.message).toBe('First operation failed')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should handle multiple consecutive errors', async () => {
|
|
215
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<number>({ key: 'test-12' }))
|
|
216
|
+
|
|
217
|
+
// Success
|
|
218
|
+
await asyncData.promise(async () => 100)()
|
|
219
|
+
expect(asyncData.data.value).toBe(100)
|
|
220
|
+
|
|
221
|
+
// First error - should rollback to 100
|
|
222
|
+
await expect(asyncData.promise(async () => {
|
|
223
|
+
throw new Error('Error 1')
|
|
224
|
+
})()).rejects.toThrow('Error 1')
|
|
225
|
+
|
|
226
|
+
expect(asyncData.state.value).toBe('error')
|
|
227
|
+
expect(asyncData.data.value).toBe(100)
|
|
228
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
229
|
+
expect(asyncData.error.value?.message).toBe('Error 1')
|
|
230
|
+
|
|
231
|
+
// Second error - should still have 100
|
|
232
|
+
await expect(asyncData.promise(async () => {
|
|
233
|
+
throw new Error('Error 2')
|
|
234
|
+
})()).rejects.toThrow('Error 2')
|
|
235
|
+
|
|
236
|
+
expect(asyncData.state.value).toBe('error')
|
|
237
|
+
expect(asyncData.data.value).toBe(100)
|
|
238
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
239
|
+
expect(asyncData.error.value?.message).toBe('Error 2')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('reset', () => {
|
|
244
|
+
it('should reset the async data state to idle', async () => {
|
|
245
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-13' }))
|
|
246
|
+
|
|
247
|
+
await asyncData.promise(async () => 'initial-data')()
|
|
248
|
+
expect(asyncData.data.value).toBe('initial-data')
|
|
249
|
+
expect(asyncData.state.value).toBe('success')
|
|
250
|
+
asyncData.reset()
|
|
251
|
+
expect(asyncData.state.value).toBe('idle')
|
|
252
|
+
expect(asyncData.data.value).toBe(null)
|
|
253
|
+
expect(asyncData.error.value).toBe(null)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('provide/inject pattern', () => {
|
|
258
|
+
it('should create independent instances when same key used in same component', async () => {
|
|
259
|
+
const key = 'shared-key'
|
|
260
|
+
|
|
261
|
+
// When multiple instances with same key are created in the same component,
|
|
262
|
+
// only the first one provides - subsequent ones get their own independent state
|
|
263
|
+
// This is expected Vue provide/inject behavior within the same component
|
|
264
|
+
const [result] = withSetup(() => {
|
|
265
|
+
const instance1 = useOptimisticAsyncData<string>({ key })
|
|
266
|
+
const instance2 = useOptimisticAsyncData<string>({ key })
|
|
267
|
+
return { instance1, instance2 }
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Update through first instance
|
|
271
|
+
await result.instance1.promise(async () => 'shared-data')()
|
|
272
|
+
|
|
273
|
+
// First instance has data
|
|
274
|
+
expect(result.instance1.data.value).toBe('shared-data')
|
|
275
|
+
expect(result.instance1.state.value).toBe('success')
|
|
276
|
+
|
|
277
|
+
// Second instance remains independent (expected behavior in same component)
|
|
278
|
+
expect(result.instance2.state.value).toBe('idle')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should allow each instance to have independent state', async () => {
|
|
282
|
+
const key = 'independent-key'
|
|
283
|
+
|
|
284
|
+
// Create two separate app instances with the same key
|
|
285
|
+
const [instance1] = withSetup(() => useOptimisticAsyncData<number>({ key }))
|
|
286
|
+
const [instance2] = withSetup(() => useOptimisticAsyncData<number>({ key }))
|
|
287
|
+
|
|
288
|
+
// Update first instance
|
|
289
|
+
await instance1.promise(async () => 100)()
|
|
290
|
+
|
|
291
|
+
// Update second instance
|
|
292
|
+
await instance2.promise(async () => 200)()
|
|
293
|
+
|
|
294
|
+
// Each instance maintains its own state
|
|
295
|
+
expect(instance1.data.value).toBe(100)
|
|
296
|
+
expect(instance2.data.value).toBe(200)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should maintain separate state for different keys', async () => {
|
|
300
|
+
// Create instances with different keys in the same app context
|
|
301
|
+
const [result] = withSetup(() => {
|
|
302
|
+
const instance1 = useOptimisticAsyncData<string>({ key: 'key-1' })
|
|
303
|
+
const instance2 = useOptimisticAsyncData<string>({ key: 'key-2' })
|
|
304
|
+
return { instance1, instance2 }
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await result.instance1.promise(async () => 'data-1')()
|
|
308
|
+
await result.instance2.promise(async () => 'data-2')()
|
|
309
|
+
|
|
310
|
+
expect(result.instance1.data.value).toBe('data-1')
|
|
311
|
+
expect(result.instance2.data.value).toBe('data-2')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should handle errors independently across different keys', async () => {
|
|
315
|
+
const [result] = withSetup(() => {
|
|
316
|
+
const instance1 = useOptimisticAsyncData<string>({ key: 'key-error-1' })
|
|
317
|
+
const instance2 = useOptimisticAsyncData<string>({ key: 'key-error-2' })
|
|
318
|
+
return { instance1, instance2 }
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
await expect(result.instance1.promise(async () => {
|
|
322
|
+
throw new Error('Instance1 error')
|
|
323
|
+
})()).rejects.toThrow('Instance1 error')
|
|
324
|
+
|
|
325
|
+
expect(result.instance1.state.value).toBe('error')
|
|
326
|
+
expect(result.instance1.error.value).toBeInstanceOf(Error)
|
|
327
|
+
expect(result.instance1.error.value?.message).toBe('Instance1 error')
|
|
328
|
+
expect(result.instance1.data.value).toBe(null)
|
|
329
|
+
|
|
330
|
+
expect(result.instance2.state.value).toBe('idle')
|
|
331
|
+
expect(result.instance2.error.value).toBe(null)
|
|
332
|
+
expect(result.instance2.data.value).toBe(null)
|
|
333
|
+
|
|
334
|
+
await expect(result.instance2.promise(async () => {
|
|
335
|
+
throw new Error('Instance2 error')
|
|
336
|
+
})()).rejects.toThrow('Instance2 error')
|
|
337
|
+
|
|
338
|
+
expect(result.instance2.state.value).toBe('error')
|
|
339
|
+
expect(result.instance2.error.value).toBeInstanceOf(Error)
|
|
340
|
+
expect(result.instance2.error.value?.message).toBe('Instance2 error')
|
|
341
|
+
expect(result.instance2.data.value).toBe(null)
|
|
342
|
+
|
|
343
|
+
expect(result.instance1.state.value).toBe('error')
|
|
344
|
+
expect(result.instance1.error.value).toBeInstanceOf(Error)
|
|
345
|
+
expect(result.instance1.error.value?.message).toBe('Instance1 error')
|
|
346
|
+
expect(result.instance1.data.value).toBe(null)
|
|
347
|
+
|
|
348
|
+
expect(result.instance2.state.value).toBe('error')
|
|
349
|
+
expect(result.instance2.error.value).toBeInstanceOf(Error)
|
|
350
|
+
expect(result.instance2.error.value?.message).toBe('Instance2 error')
|
|
351
|
+
expect(result.instance2.data.value).toBe(null)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('should handle errors independently when created in same component', async () => {
|
|
355
|
+
const key = 'error-key'
|
|
356
|
+
|
|
357
|
+
const [result] = withSetup(() => {
|
|
358
|
+
const instance1 = useOptimisticAsyncData<string>({ key })
|
|
359
|
+
const instance2 = useOptimisticAsyncData<string>({ key })
|
|
360
|
+
return { instance1, instance2 }
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
await expect(result.instance1.promise(async () => {
|
|
364
|
+
throw new Error('Instance1 error')
|
|
365
|
+
})()).rejects.toThrow('Instance1 error')
|
|
366
|
+
|
|
367
|
+
// First instance has error
|
|
368
|
+
expect(result.instance1.state.value).toBe('error')
|
|
369
|
+
expect(result.instance1.error.value?.message).toBe('Instance1 error')
|
|
370
|
+
expect(result.instance1.data.value).toBe(null)
|
|
371
|
+
|
|
372
|
+
// Second instance remains independent
|
|
373
|
+
expect(result.instance2.state.value).toBe('idle')
|
|
374
|
+
expect(result.instance2.error.value).toBe(null)
|
|
375
|
+
expect(result.instance2.data.value).toBe(null)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe('concurrent operations', () => {
|
|
380
|
+
it('should handle multiple concurrent promises', async () => {
|
|
381
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<number>({ key: 'test-concurrent-1' }))
|
|
382
|
+
|
|
383
|
+
// Start multiple operations
|
|
384
|
+
const promise1 = asyncData.promise(async () => {
|
|
385
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
386
|
+
return 1
|
|
387
|
+
})()
|
|
388
|
+
|
|
389
|
+
const promise2 = asyncData.promise(async () => {
|
|
390
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
391
|
+
return 2
|
|
392
|
+
})()
|
|
393
|
+
|
|
394
|
+
const promise3 = asyncData.promise(async () => {
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
396
|
+
return 3
|
|
397
|
+
})()
|
|
398
|
+
|
|
399
|
+
await Promise.all([promise1, promise2, promise3])
|
|
400
|
+
|
|
401
|
+
// Last operation wins
|
|
402
|
+
expect(asyncData.data.value).toBeDefined()
|
|
403
|
+
expect(asyncData.state.value).toBe('success')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('should maintain state consistency with rapid successive calls', async () => {
|
|
407
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<number>({ key: 'test-concurrent-2' }))
|
|
408
|
+
|
|
409
|
+
// Rapid successive operations
|
|
410
|
+
await asyncData.promise(async () => 1)()
|
|
411
|
+
await asyncData.promise(async () => 2)()
|
|
412
|
+
await asyncData.promise(async () => 3)()
|
|
413
|
+
await asyncData.promise(async () => 4)()
|
|
414
|
+
await asyncData.promise(async () => 5)()
|
|
415
|
+
|
|
416
|
+
expect(asyncData.data.value).toBe(5)
|
|
417
|
+
expect(asyncData.state.value).toBe('success')
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
describe('computed properties reactivity', () => {
|
|
422
|
+
it('should trigger watchers on state changes', async () => {
|
|
423
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-watch-1' }))
|
|
424
|
+
|
|
425
|
+
const stateChanges: string[] = []
|
|
426
|
+
watch(() => asyncData.state.value, (newState) => {
|
|
427
|
+
stateChanges.push(newState)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
await asyncData.promise(async () => 'test-data')()
|
|
431
|
+
|
|
432
|
+
expect(stateChanges).toContain('pending')
|
|
433
|
+
expect(stateChanges).toContain('success')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('should trigger watchers on data changes', async () => {
|
|
437
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<number>({ key: 'test-watch-2' }))
|
|
438
|
+
|
|
439
|
+
const dataChanges: (number | null)[] = []
|
|
440
|
+
watch(() => asyncData.data.value, (newData) => {
|
|
441
|
+
dataChanges.push(newData)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
await asyncData.promise(async () => 42)()
|
|
445
|
+
|
|
446
|
+
expect(dataChanges).toContain(42)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('should trigger watchers on error changes', async () => {
|
|
450
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData({ key: 'test-watch-3' }))
|
|
451
|
+
|
|
452
|
+
const errorChanges: (Error | null)[] = []
|
|
453
|
+
watch(() => asyncData.error.value, (newError) => {
|
|
454
|
+
errorChanges.push(newError)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
await expect(asyncData.promise(async () => {
|
|
458
|
+
throw new Error('Watch error')
|
|
459
|
+
})()).rejects.toThrow('Watch error')
|
|
460
|
+
|
|
461
|
+
expect(errorChanges.some(err => err?.message === 'Watch error')).toBe(true)
|
|
462
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
463
|
+
expect(asyncData.error.value?.message).toBe('Watch error')
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('edge cases', () => {
|
|
468
|
+
it('should handle async functions that return undefined', async () => {
|
|
469
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<undefined>({ key: 'test-edge-1' }))
|
|
470
|
+
|
|
471
|
+
const result = await asyncData.promise(async () => undefined)()
|
|
472
|
+
|
|
473
|
+
expect(result).toBe(undefined)
|
|
474
|
+
expect(asyncData.state.value).toBe('success')
|
|
475
|
+
expect(asyncData.data.value).toBe(undefined)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('should handle async functions that return null', async () => {
|
|
479
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<null>({ key: 'test-edge-2' }))
|
|
480
|
+
|
|
481
|
+
const result = await asyncData.promise(async () => null)()
|
|
482
|
+
|
|
483
|
+
expect(result).toBe(null)
|
|
484
|
+
expect(asyncData.state.value).toBe('success')
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('should handle complex nested objects', async () => {
|
|
488
|
+
interface ComplexData {
|
|
489
|
+
user: { id: number, name: string }
|
|
490
|
+
items: Array<{ id: string, value: number }>
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<ComplexData>({ key: 'test-edge-3' }))
|
|
494
|
+
|
|
495
|
+
const complexData: ComplexData = {
|
|
496
|
+
user: { id: 1, name: 'Test User' },
|
|
497
|
+
items: [
|
|
498
|
+
{ id: 'a', value: 10 },
|
|
499
|
+
{ id: 'b', value: 20 },
|
|
500
|
+
],
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await asyncData.promise(async () => complexData)()
|
|
504
|
+
|
|
505
|
+
expect(asyncData.data.value).toEqual(complexData)
|
|
506
|
+
expect(asyncData.data.value?.user.name).toBe('Test User')
|
|
507
|
+
expect(asyncData.data.value?.items).toHaveLength(2)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('should handle very fast operations', async () => {
|
|
511
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-edge-4' }))
|
|
512
|
+
|
|
513
|
+
// Operation that completes immediately
|
|
514
|
+
await asyncData.promise(async () => 'instant')()
|
|
515
|
+
|
|
516
|
+
expect(asyncData.state.value).toBe('success')
|
|
517
|
+
expect(asyncData.data.value).toBe('instant')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('should allow recovery from error state', async () => {
|
|
521
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<string>({ key: 'test-edge-5' }))
|
|
522
|
+
|
|
523
|
+
// Initial success
|
|
524
|
+
await asyncData.promise(async () => 'initial')()
|
|
525
|
+
expect(asyncData.state.value).toBe('success')
|
|
526
|
+
|
|
527
|
+
// Error
|
|
528
|
+
await expect(asyncData.promise(async () => {
|
|
529
|
+
throw new Error('Temporary error')
|
|
530
|
+
})()).rejects.toThrow('Temporary error')
|
|
531
|
+
expect(asyncData.state.value).toBe('error')
|
|
532
|
+
expect(asyncData.data.value).toBe('initial')
|
|
533
|
+
expect(asyncData.error.value).toBeInstanceOf(Error)
|
|
534
|
+
expect(asyncData.error.value?.message).toBe('Temporary error')
|
|
535
|
+
|
|
536
|
+
// Recovery
|
|
537
|
+
await asyncData.promise(async () => 'recovered')()
|
|
538
|
+
expect(asyncData.state.value).toBe('success')
|
|
539
|
+
expect(asyncData.data.value).toBe('recovered')
|
|
540
|
+
expect(asyncData.error.value).toBe(null)
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
describe('type safety', () => {
|
|
545
|
+
it('should maintain type safety for generic data types', async () => {
|
|
546
|
+
interface User {
|
|
547
|
+
id: number
|
|
548
|
+
email: string
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<User>({ key: 'test-types-1' }))
|
|
552
|
+
|
|
553
|
+
await asyncData.promise(async () => ({
|
|
554
|
+
id: 1,
|
|
555
|
+
email: 'test@example.com',
|
|
556
|
+
}))()
|
|
557
|
+
|
|
558
|
+
// TypeScript should infer the correct type
|
|
559
|
+
const user = asyncData.data.value
|
|
560
|
+
if (user) {
|
|
561
|
+
expect(user.id).toBe(1)
|
|
562
|
+
expect(user.email).toBe('test@example.com')
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('should handle union types', async () => {
|
|
567
|
+
type Result = { success: true, data: string } | { success: false, error: string }
|
|
568
|
+
|
|
569
|
+
const [asyncData] = withSetup(() => useOptimisticAsyncData<Result>({ key: 'test-types-2' }))
|
|
570
|
+
|
|
571
|
+
await asyncData.promise(async () => ({
|
|
572
|
+
success: true,
|
|
573
|
+
data: 'test',
|
|
574
|
+
}))()
|
|
575
|
+
|
|
576
|
+
const result = asyncData.data.value
|
|
577
|
+
expect(result).toBeDefined()
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
describe('injection key caching', () => {
|
|
582
|
+
it('should cache injection keys for reuse', async () => {
|
|
583
|
+
const key = 'cached-key-test'
|
|
584
|
+
|
|
585
|
+
// Create multiple instances with same key
|
|
586
|
+
const [instance1] = withSetup(() => useOptimisticAsyncData({ key }))
|
|
587
|
+
const [instance2] = withSetup(() => useOptimisticAsyncData({ key }))
|
|
588
|
+
const [instance3] = withSetup(() => useOptimisticAsyncData({ key }))
|
|
589
|
+
|
|
590
|
+
// Each instance can work independently
|
|
591
|
+
await instance1.promise(async () => 'value-1')()
|
|
592
|
+
await instance2.promise(async () => 'value-2')()
|
|
593
|
+
await instance3.promise(async () => 'value-3')()
|
|
594
|
+
|
|
595
|
+
// All instances have their own state
|
|
596
|
+
expect(instance1.data.value).toBe('value-1')
|
|
597
|
+
expect(instance2.data.value).toBe('value-2')
|
|
598
|
+
expect(instance3.data.value).toBe('value-3')
|
|
599
|
+
|
|
600
|
+
// But they all use the same key
|
|
601
|
+
expect(instance1.key).toBe(key)
|
|
602
|
+
expect(instance2.key).toBe(key)
|
|
603
|
+
expect(instance3.key).toBe(key)
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
})
|