@vobs/resource 0.1.0 → 1.0.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.
@@ -0,0 +1,460 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createElement, createText, createVobs, type VobsPlugin } from '@vobs/vobs'
3
+ import { createMemoryHistory, createRouter, routerPlugin } from '@vobs/router'
4
+ import { createOwner, state } from '@vobs/reactivity'
5
+ import {
6
+ createResourceClient,
7
+ insertResourceBoundary,
8
+ RESOURCE_KEY,
9
+ resourcePlugin,
10
+ resourceRouterPlugin,
11
+ stableSerialize,
12
+ type ResourceRoutePrefetchContext
13
+ } from './index'
14
+
15
+ describe('@vobs/resource', () => {
16
+ it('自动请求并暴露响应式状态', async () => {
17
+ const client = createResourceClient()
18
+ const users = client.resource(() => Promise.resolve([{ id: 1 }]))
19
+
20
+ expect(users.loading.value).toBe(true)
21
+ await users.refetch()
22
+ expect(users.data.value).toEqual([{ id: 1 }])
23
+ expect(users.error.value).toBeNull()
24
+ expect(users.loading.value).toBe(false)
25
+ })
26
+
27
+ it('prefetchAll 会等待无 key Resource 的进行中请求', async () => {
28
+ let resolve!: (value: string) => void
29
+ let settled = false
30
+ const client = createResourceClient()
31
+ client.resource(() => new Promise<string>(done => { resolve = done }))
32
+
33
+ const prefetch = client.prefetchAll().then(() => { settled = true })
34
+ await Promise.resolve()
35
+ expect(settled).toBe(false)
36
+
37
+ resolve('ready')
38
+ await prefetch
39
+ expect(settled).toBe(true)
40
+ client.clear()
41
+ })
42
+
43
+ it('客户端清理时通过 ResourceFetcher 的 AbortSignal 取消请求', async () => {
44
+ let requestSignal: AbortSignal | undefined
45
+ const client = createResourceClient()
46
+ client.resource(signal => new Promise<string>((_, reject) => {
47
+ requestSignal = signal
48
+ signal.addEventListener('abort', () => {
49
+ reject(Object.assign(new Error('cancelled'), { name: 'AbortError' }))
50
+ })
51
+ }))
52
+
53
+ await Promise.resolve()
54
+ expect(requestSignal).toBeDefined()
55
+ client.clear()
56
+ expect(requestSignal?.aborted).toBe(true)
57
+ await Promise.resolve()
58
+ })
59
+
60
+ it('相同 key 共享缓存和进行中的请求', async () => {
61
+ const client = createResourceClient()
62
+ const fetcher = vi.fn(() => Promise.resolve([{ id: 1 }]))
63
+ const a = client.resource({ key: ['users', { page: 1 }], fetcher, staleTime: 1_000 })
64
+ const b = client.resource({ key: ['users', { page: 1 }], fetcher, staleTime: 1_000 })
65
+
66
+ await Promise.all([a.refetch(), b.refetch()])
67
+ expect(fetcher).toHaveBeenCalledTimes(1)
68
+ expect(a.data).toBe(b.data)
69
+ })
70
+
71
+ it('缓存未过期时复用成功结果,失效后重新请求', async () => {
72
+ const client = createResourceClient()
73
+ const fetcher = vi.fn(() => Promise.resolve(fetcher.mock.calls.length))
74
+ const first = client.resource({ key: ['counter'], fetcher, staleTime: 1_000 })
75
+ await first.refetch()
76
+ const second = client.resource({ key: ['counter'], fetcher, staleTime: 1_000 })
77
+ await Promise.resolve()
78
+ expect(fetcher).toHaveBeenCalledTimes(1)
79
+ expect(second.data.value).toBe(1)
80
+
81
+ client.invalidate(['counter'])
82
+ await second.refetch()
83
+ expect(fetcher).toHaveBeenCalledTimes(2)
84
+ })
85
+
86
+ it('mutate 写入共享缓存', async () => {
87
+ const client = createResourceClient()
88
+ const a = client.resource({ key: ['count'], fetcher: () => Promise.resolve(1), staleTime: 1_000 })
89
+ const b = client.resource({ key: ['count'], fetcher: () => Promise.resolve(1), staleTime: 1_000 })
90
+ await a.refetch()
91
+
92
+ b.mutate(current => (current ?? 0) + 1)
93
+ expect(a.data.value).toBe(2)
94
+ expect(client.get<number>(['count'])?.data).toBe(2)
95
+ })
96
+
97
+ it('飞行中请求完成时不得覆盖 mutate 写入的新数据', async () => {
98
+ let resolveRequest!: (value: number) => void
99
+ const client = createResourceClient()
100
+ const count = client.resource(() => new Promise<number>(resolve => { resolveRequest = resolve }))
101
+ await flushMicrotasks()
102
+ expect(count.loading.value).toBe(true)
103
+
104
+ count.mutate(100)
105
+ resolveRequest(1)
106
+ await flushMicrotasks()
107
+
108
+ expect(count.loading.value).toBe(false)
109
+ expect(count.data.value).toBe(100)
110
+ expect(count.error.value).toBeNull()
111
+ })
112
+
113
+ it('飞行中请求失败时不得把旧错误写回 mutate 后的状态', async () => {
114
+ let rejectRequest!: (reason: Error) => void
115
+ const client = createResourceClient()
116
+ const count = client.resource(() => new Promise<number>((_, reject) => { rejectRequest = reject }))
117
+ await flushMicrotasks()
118
+
119
+ count.mutate(100)
120
+ rejectRequest(new Error('stale failure'))
121
+ await flushMicrotasks()
122
+
123
+ expect(count.error.value).toBeNull()
124
+ expect(count.data.value).toBe(100)
125
+ })
126
+
127
+ it('将失败归一化为 Error 并通知客户端', async () => {
128
+ const onError = vi.fn()
129
+ const client = createResourceClient({ onError })
130
+ const users = client.resource({ key: ['users'], fetcher: () => Promise.reject('offline') })
131
+
132
+ await expect(users.refetch()).rejects.toThrow('offline')
133
+ expect(users.error.value).toBeInstanceOf(Error)
134
+ expect(onError).toHaveBeenCalledTimes(1)
135
+ })
136
+
137
+ it('按配置重试失败的请求,并只报告最终失败', async () => {
138
+ const onError = vi.fn()
139
+ const client = createResourceClient({ onError })
140
+ let attempts = 0
141
+ const users = client.resource({
142
+ key: ['users'],
143
+ retry: 2,
144
+ fetcher: () => {
145
+ attempts++
146
+ return attempts < 3 ? Promise.reject(new Error('offline')) : Promise.resolve(['Ada'])
147
+ }
148
+ })
149
+
150
+ await users.prefetch()
151
+ expect(attempts).toBe(3)
152
+ expect(users.data.value).toEqual(['Ada'])
153
+ expect(onError).not.toHaveBeenCalled()
154
+ client.clear()
155
+ })
156
+
157
+ it('乐观更新在 action 失败时回滚,并保留错误状态', async () => {
158
+ const client = createResourceClient()
159
+ const count = client.resource({ key: ['count'], fetcher: () => Promise.resolve(1) })
160
+ await count.refetch()
161
+
162
+ await expect(count.optimistic(value => (value ?? 0) + 1, () => Promise.reject('rejected')))
163
+ .rejects.toThrow('rejected')
164
+ expect(count.data.value).toBe(1)
165
+ expect(count.error.value?.message).toBe('rejected')
166
+ client.clear()
167
+ })
168
+
169
+ it('较早的乐观操作失败时不能回滚较晚的写入', async () => {
170
+ let rejectFirst!: (reason: unknown) => void
171
+ const client = createResourceClient()
172
+ const count = client.resource({ key: ['count'], fetcher: () => Promise.resolve(1) })
173
+ await count.refetch()
174
+ const first = count.optimistic(value => (value ?? 0) + 1, () => new Promise<never>((_, reject) => {
175
+ rejectFirst = reject
176
+ }))
177
+ count.mutate(value => (value ?? 0) + 10)
178
+ rejectFirst('first failed')
179
+
180
+ await expect(first).rejects.toThrow('first failed')
181
+ expect(count.data.value).toBe(12)
182
+ expect(count.error.value).toBeNull()
183
+ client.clear()
184
+ })
185
+
186
+ it('插件提供共享客户端给依赖它的插件', () => {
187
+ const client = createResourceClient()
188
+ let injected: typeof client | undefined
189
+ const consumer: VobsPlugin = {
190
+ name: 'consumer',
191
+ requires: [resourcePlugin({ client })],
192
+ install(context) { injected = context.inject(RESOURCE_KEY) }
193
+ }
194
+ const app = createVobs({ render: () => createText(''), plugins: [consumer] })
195
+
196
+ expect(injected).toBe(client)
197
+ app.destroy()
198
+ })
199
+
200
+ it('路由进入前并行预取 meta 中的数据,并复用客户端缓存', async () => {
201
+ let calls = 0
202
+ const client = createResourceClient()
203
+ const router = createRouter({
204
+ history: createMemoryHistory('/'),
205
+ routes: [
206
+ { path: '/', component: () => createText('home') },
207
+ {
208
+ path: '/users',
209
+ component: () => createText('users'),
210
+ meta: {
211
+ prefetch: [
212
+ async ({ client: currentClient }: ResourceRoutePrefetchContext) => {
213
+ const users = currentClient.resource({
214
+ key: ['users'],
215
+ fetcher: async () => {
216
+ calls++
217
+ return ['Ada']
218
+ },
219
+ staleTime: 1_000
220
+ })
221
+ await users.prefetch()
222
+ },
223
+ async ({ route }: ResourceRoutePrefetchContext) => {
224
+ expect(route.path).toBe('/users')
225
+ }
226
+ ]
227
+ }
228
+ }
229
+ ]
230
+ })
231
+ const app = createVobs({
232
+ render: () => createText('app'),
233
+ plugins: [
234
+ routerPlugin({ router }),
235
+ resourcePlugin({ client }),
236
+ resourceRouterPlugin()
237
+ ]
238
+ })
239
+
240
+ await router.push('/users')
241
+ await router.push('/')
242
+ await router.push('/users')
243
+ expect(calls).toBe(1)
244
+ app.destroy()
245
+ router.destroy()
246
+ client.clear()
247
+ })
248
+
249
+ it('路由预取失败时取消导航并保留当前路由', async () => {
250
+ const router = createRouter({
251
+ history: createMemoryHistory('/'),
252
+ routes: [
253
+ { path: '/', component: () => createText('home') },
254
+ {
255
+ path: '/private',
256
+ component: () => createText('private'),
257
+ meta: { prefetch: () => Promise.reject(new Error('prefetch failed')) }
258
+ }
259
+ ]
260
+ })
261
+ const client = createResourceClient()
262
+ const app = createVobs({
263
+ render: () => createText('app'),
264
+ plugins: [
265
+ routerPlugin({ router }),
266
+ resourcePlugin({ client }),
267
+ resourceRouterPlugin()
268
+ ]
269
+ })
270
+
271
+ await expect(router.push('/private')).rejects.toThrow('prefetch failed')
272
+ expect(router.currentRoute.value.path).toBe('/')
273
+ app.destroy()
274
+ router.destroy()
275
+ client.clear()
276
+ })
277
+
278
+ it('响应式 key 切换时取消旧请求并切换到新缓存条目', async () => {
279
+ const key = state<readonly unknown[]>(['first'])
280
+ const signals = new Map<string, AbortSignal>()
281
+ const resolve = new Map<string, (value: string) => void>()
282
+ const client = createResourceClient()
283
+ const item = client.resource({
284
+ key,
285
+ fetcher: currentSignal => new Promise<string>((done, reject) => {
286
+ const name = String(key.value[0])
287
+ signals.set(name, currentSignal)
288
+ resolve.set(name, done)
289
+ currentSignal.addEventListener('abort', () => reject(new Error('cancelled')))
290
+ })
291
+ })
292
+
293
+ await vi.waitFor(() => expect(signals.get('first')).toBeDefined())
294
+ key.value = ['second']
295
+ await vi.waitFor(() => expect(signals.get('second')).toBeDefined())
296
+ expect(signals.get('first')?.aborted).toBe(true)
297
+ expect(item.key).toEqual(['second'])
298
+
299
+ resolve.get('second')?.('ready')
300
+ await item.prefetch()
301
+ expect(item.data.value).toBe('ready')
302
+ item.dispose()
303
+ client.clear()
304
+ key.dispose()
305
+ })
306
+
307
+ it('stale-while-revalidate 先返回缓存并在后台更新', async () => {
308
+ let calls = 0
309
+ let resolveRefresh!: (value: string) => void
310
+ const client = createResourceClient()
311
+ const item = client.resource({
312
+ key: ['swr'],
313
+ staleTime: 0,
314
+ strategy: 'stale-while-revalidate',
315
+ fetcher: () => {
316
+ calls++
317
+ return calls === 1 ? Promise.resolve('old') : new Promise<string>(resolve => {
318
+ resolveRefresh = resolve
319
+ })
320
+ }
321
+ })
322
+
323
+ await item.prefetch()
324
+ item.invalidate()
325
+ const cached = await item.prefetch()
326
+ expect(cached).toBe('old')
327
+ expect(item.data.value).toBe('old')
328
+ expect(item.loading.value).toBe(true)
329
+ resolveRefresh('new')
330
+ await vi.waitFor(() => expect(item.data.value).toBe('new'))
331
+ expect(calls).toBe(2)
332
+ client.clear()
333
+ })
334
+
335
+ it('资源所属 Owner 销毁时取消实例请求', async () => {
336
+ let requestSignal: AbortSignal | undefined
337
+ const owner = createOwner()
338
+ const client = createResourceClient()
339
+ owner.run(() => {
340
+ client.resource(signal => new Promise<string>((_, reject) => {
341
+ requestSignal = signal
342
+ signal.addEventListener('abort', () => reject(new Error('cancelled')))
343
+ }))
344
+ })
345
+ await Promise.resolve()
346
+ owner.dispose()
347
+ expect(requestSignal?.aborted).toBe(true)
348
+ client.clear()
349
+ })
350
+
351
+ it('稳定序列化对象 key,拒绝循环引用', () => {
352
+ expect(stableSerialize(['users', { page: 1, filter: 'all' }]))
353
+ .toBe(stableSerialize(['users', { filter: 'all', page: 1 }]))
354
+ const circular: { self?: unknown } = {}
355
+ circular.self = circular
356
+ expect(() => stableSerialize(circular)).toThrow('循环引用')
357
+ })
358
+
359
+ it('ResourceBoundary 在加载、成功和空数据状态间切换', async () => {
360
+ let resolve!: (value: number | null) => void
361
+ const client = createResourceClient()
362
+ const count = client.resource({
363
+ key: ['count'],
364
+ fetcher: () => new Promise<number | null>(done => { resolve = done })
365
+ })
366
+ const app = createVobs({
367
+ render: () => {
368
+ const root = createElement('div')
369
+ insertResourceBoundary(root, null, {
370
+ resource: count,
371
+ loading: () => createText('loading'),
372
+ empty: () => createText('empty'),
373
+ children: value => createText(`count: ${value}`)
374
+ })
375
+ return root
376
+ }
377
+ })
378
+ const container = document.createElement('div')
379
+ app.mount(container)
380
+ expect(container.textContent).toBe('loading')
381
+
382
+ await Promise.resolve()
383
+ resolve(2)
384
+ await count.prefetch()
385
+ app.update()
386
+ expect(container.textContent).toBe('count: 2')
387
+
388
+ count.mutate(null)
389
+ app.update()
390
+ expect(container.textContent).toBe('empty')
391
+ app.destroy()
392
+ client.clear()
393
+ })
394
+
395
+ it('ResourceBoundary 将失败交给 fallback,并提供重试函数', async () => {
396
+ let attempts = 0
397
+ const client = createResourceClient()
398
+ const users = client.resource({
399
+ key: ['users'],
400
+ fetcher: () => {
401
+ attempts++
402
+ return attempts === 1 ? Promise.reject(new Error('offline')) : Promise.resolve(['Ada'])
403
+ }
404
+ })
405
+ let retry: (() => Promise<unknown>) | undefined
406
+ const app = createVobs({
407
+ render: () => {
408
+ const root = createElement('div')
409
+ insertResourceBoundary(root, null, {
410
+ resource: users,
411
+ fallback: (error, nextRetry) => {
412
+ retry = nextRetry
413
+ return createText(`error: ${error.message}`)
414
+ },
415
+ children: names => createText(names.join(', '))
416
+ })
417
+ return root
418
+ }
419
+ })
420
+ const container = document.createElement('div')
421
+ app.mount(container)
422
+
423
+ await expect(users.prefetch()).rejects.toThrow('offline')
424
+ app.update()
425
+ expect(container.textContent).toBe('error: offline')
426
+ expect(retry).toBeDefined()
427
+ await retry!()
428
+ app.update()
429
+ expect(container.textContent).toBe('Ada')
430
+ app.destroy()
431
+ client.clear()
432
+ })
433
+
434
+ it('ResourceBoundary 将 children 渲染异常交给统一错误边界', async () => {
435
+ const client = createResourceClient()
436
+ const resource = client.resource({ key: ['render-error'], fetcher: () => Promise.resolve('data') })
437
+ await resource.prefetch()
438
+ const app = createVobs({
439
+ render: () => {
440
+ const root = createElement('div')
441
+ insertResourceBoundary(root, null, {
442
+ resource,
443
+ fallback: error => createText(`fallback: ${error.message}`),
444
+ children: () => { throw new Error('render failed') }
445
+ })
446
+ return root
447
+ }
448
+ })
449
+ const container = document.createElement('div')
450
+ app.mount(container)
451
+ app.update()
452
+ expect(container.textContent).toBe('fallback: render failed')
453
+ app.destroy()
454
+ client.clear()
455
+ })
456
+ })
457
+
458
+ async function flushMicrotasks(times = 8): Promise<void> {
459
+ for (let index = 0; index < times; index++) await Promise.resolve()
460
+ }