@sanity/sdk 2.15.0 → 2.16.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/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/handleAuthCallback.test.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +199 -477
- package/src/auth/refreshStampedToken.ts +67 -195
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/document/sharedListener.test.ts +1 -0
- package/src/document/sharedListener.ts +2 -0
- package/src/query/queryStore.test.ts +232 -38
- package/src/query/queryStore.ts +47 -47
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +0 -16
- package/src/releases/getPerspectiveState.test.ts +3 -8
- package/src/releases/getPerspectiveState.ts +6 -19
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +51 -53
- package/src/releases/releasesStore.ts +32 -32
- package/src/releases/utils/sortReleases.test.ts +36 -0
- package/src/releases/utils/sortReleases.ts +14 -9
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConnectionFailedError,
|
|
3
|
+
CorsOriginError,
|
|
4
|
+
DisconnectError,
|
|
5
|
+
type LiveEvent,
|
|
6
|
+
type SanityClient,
|
|
7
|
+
type SyncTag,
|
|
8
|
+
} from '@sanity/client'
|
|
2
9
|
import {delay, filter, firstValueFrom, Observable, of, Subject} from 'rxjs'
|
|
3
10
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
11
|
|
|
5
12
|
import {getClientState} from '../client/clientStore'
|
|
13
|
+
import {LIVE_EVENTS_RETRY_DELAY} from '../client/liveEvents'
|
|
6
14
|
import {isCanvasResource} from '../config/sanityConfig'
|
|
7
15
|
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
8
16
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
9
17
|
import {getQueryState, resolveQuery} from './queryStore'
|
|
10
|
-
|
|
11
|
-
vi.mock('./queryStoreConstants', async (importOriginal) => ({
|
|
12
|
-
...(await importOriginal<typeof import('./queryStoreConstants')>()),
|
|
13
|
-
QUERY_STATE_CLEAR_DELAY: 10,
|
|
14
|
-
}))
|
|
18
|
+
import {QUERY_STATE_CLEAR_DELAY} from './queryStoreConstants'
|
|
15
19
|
|
|
16
20
|
vi.mock('../client/clientStore', () => ({
|
|
17
21
|
getClientState: vi.fn(),
|
|
@@ -34,6 +38,14 @@ vi.mock('../releases/getPerspectiveState', async () => {
|
|
|
34
38
|
}
|
|
35
39
|
})
|
|
36
40
|
|
|
41
|
+
// With fake timers, an emission gated on a pending rxjs delay or cleanup
|
|
42
|
+
// timeout never arrives on its own: create the promise first, advance the
|
|
43
|
+
// clock, then await it.
|
|
44
|
+
async function advanceAndAwait<T>(promise: Promise<T>, ms = 0): Promise<T> {
|
|
45
|
+
await vi.advanceTimersByTimeAsync(ms)
|
|
46
|
+
return promise
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
describe('queryStore', () => {
|
|
38
50
|
let instance: SanityInstance
|
|
39
51
|
let liveEvents: Subject<LiveEvent>
|
|
@@ -48,6 +60,7 @@ describe('queryStore', () => {
|
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers()
|
|
51
64
|
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
52
65
|
|
|
53
66
|
fetch = vi
|
|
@@ -76,6 +89,7 @@ describe('queryStore', () => {
|
|
|
76
89
|
afterEach(() => {
|
|
77
90
|
vi.mocked(getClientState).mockClear()
|
|
78
91
|
instance.dispose()
|
|
92
|
+
vi.useRealTimers()
|
|
79
93
|
})
|
|
80
94
|
|
|
81
95
|
it('initializes query state and cleans up after unsubscribe', async () => {
|
|
@@ -89,7 +103,7 @@ describe('queryStore', () => {
|
|
|
89
103
|
const unsubscribe = state.subscribe()
|
|
90
104
|
|
|
91
105
|
// Wait for data to be fetched
|
|
92
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
106
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
93
107
|
|
|
94
108
|
// Verify data is present
|
|
95
109
|
expect(state.getCurrent()).toEqual([
|
|
@@ -101,7 +115,7 @@ describe('queryStore', () => {
|
|
|
101
115
|
unsubscribe()
|
|
102
116
|
|
|
103
117
|
// Wait for the cleanup delay
|
|
104
|
-
await
|
|
118
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
105
119
|
|
|
106
120
|
// Verify state is cleared
|
|
107
121
|
expect(state.getCurrent()).toBeUndefined()
|
|
@@ -116,7 +130,7 @@ describe('queryStore', () => {
|
|
|
116
130
|
const unsubscribe2 = state.subscribe()
|
|
117
131
|
|
|
118
132
|
// Wait for data to be fetched
|
|
119
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
133
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
120
134
|
|
|
121
135
|
// Verify data is present
|
|
122
136
|
expect(state.getCurrent()).toEqual([
|
|
@@ -137,7 +151,7 @@ describe('queryStore', () => {
|
|
|
137
151
|
unsubscribe2()
|
|
138
152
|
|
|
139
153
|
// Wait for cleanup delay
|
|
140
|
-
await
|
|
154
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
141
155
|
|
|
142
156
|
// Verify state is cleared after all subscribers are gone
|
|
143
157
|
expect(state.getCurrent()).toBeUndefined()
|
|
@@ -151,17 +165,17 @@ describe('queryStore', () => {
|
|
|
151
165
|
// Check that getQueryState starts undefined
|
|
152
166
|
expect(state.getCurrent()).toBeUndefined()
|
|
153
167
|
|
|
154
|
-
//
|
|
155
|
-
|
|
168
|
+
// resolveQuery holds only a temporary subscriber (released after the
|
|
169
|
+
// clear delay once the promise settles)
|
|
170
|
+
const result = await advanceAndAwait(resolveQuery(instance, {query}))
|
|
156
171
|
expect(result).toEqual([
|
|
157
172
|
{_id: 'movie1', _type: 'movie', title: 'Movie 1'},
|
|
158
173
|
{_id: 'movie2', _type: 'movie', title: 'Movie 2'},
|
|
159
174
|
])
|
|
160
175
|
|
|
161
|
-
// Check that getQueryState
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// subscriber that can trigger a clean up
|
|
176
|
+
// Check that getQueryState is resolved now. This behavior is important
|
|
177
|
+
// for supporting suspense: the resolved state must remain readable while
|
|
178
|
+
// React re-renders and attaches a lasting subscriber
|
|
165
179
|
expect(state.getCurrent()).toEqual([
|
|
166
180
|
{_id: 'movie1', _type: 'movie', title: 'Movie 1'},
|
|
167
181
|
{_id: 'movie2', _type: 'movie', title: 'Movie 2'},
|
|
@@ -170,7 +184,7 @@ describe('queryStore', () => {
|
|
|
170
184
|
// Subscribing and unsubscribing should nuke the state now
|
|
171
185
|
const unsubscribe = state.subscribe()
|
|
172
186
|
unsubscribe()
|
|
173
|
-
await
|
|
187
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
174
188
|
expect(state.getCurrent()).toBeUndefined()
|
|
175
189
|
})
|
|
176
190
|
|
|
@@ -189,6 +203,13 @@ describe('queryStore', () => {
|
|
|
189
203
|
|
|
190
204
|
// Verify state is cleared after abort
|
|
191
205
|
expect(getQueryState(instance, {query}).getCurrent()).toBeUndefined()
|
|
206
|
+
|
|
207
|
+
// The key must be removed immediately (not after the clear delay): a new
|
|
208
|
+
// resolveQuery re-adds it, which only triggers a fresh fetch if the abort
|
|
209
|
+
// actually released the previous temporary subscriber
|
|
210
|
+
const callsBefore = vi.mocked(fetch).mock.calls.length
|
|
211
|
+
await expect(advanceAndAwait(resolveQuery(instance, {query}))).resolves.toEqual(mockData.movies)
|
|
212
|
+
expect(vi.mocked(fetch).mock.calls.length).toBe(callsBefore + 1)
|
|
192
213
|
})
|
|
193
214
|
|
|
194
215
|
it('refetches query when receiving live event with matching sync tag', async () => {
|
|
@@ -210,7 +231,7 @@ describe('queryStore', () => {
|
|
|
210
231
|
const state = getQueryState<{_id: string; _type: string; title: string}[]>(instance, {query})
|
|
211
232
|
|
|
212
233
|
const unsubscribe = state.subscribe()
|
|
213
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
234
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
214
235
|
|
|
215
236
|
// Emit live event with matching sync tag
|
|
216
237
|
liveEvents.next({
|
|
@@ -222,7 +243,9 @@ describe('queryStore', () => {
|
|
|
222
243
|
} as LiveEvent)
|
|
223
244
|
|
|
224
245
|
// Wait for updated data
|
|
225
|
-
const result = await
|
|
246
|
+
const result = await advanceAndAwait(
|
|
247
|
+
firstValueFrom(state.observable.pipe(filter((data) => data?.length === 3))),
|
|
248
|
+
)
|
|
226
249
|
|
|
227
250
|
expect(fetch).toHaveBeenCalledTimes(2)
|
|
228
251
|
expect(vi.mocked(fetch).mock.calls[1][2]?.lastLiveEventId).toBe('event1')
|
|
@@ -241,7 +264,7 @@ describe('queryStore', () => {
|
|
|
241
264
|
const state = getQueryState(instance, {query})
|
|
242
265
|
|
|
243
266
|
const unsubscribe = state.subscribe()
|
|
244
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
267
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
245
268
|
|
|
246
269
|
// Emit event with different tag
|
|
247
270
|
liveEvents.next({
|
|
@@ -252,7 +275,7 @@ describe('queryStore', () => {
|
|
|
252
275
|
event: 'created',
|
|
253
276
|
} as LiveEvent)
|
|
254
277
|
|
|
255
|
-
await
|
|
278
|
+
await vi.advanceTimersByTimeAsync(50) // Allow time for potential refetch
|
|
256
279
|
expect(fetch).toHaveBeenCalledTimes(1)
|
|
257
280
|
|
|
258
281
|
unsubscribe()
|
|
@@ -274,7 +297,7 @@ describe('queryStore', () => {
|
|
|
274
297
|
const state = getQueryState(instance, {query})
|
|
275
298
|
|
|
276
299
|
const unsubscribe = state.subscribe()
|
|
277
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
300
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
278
301
|
|
|
279
302
|
// Emit two events with same tag
|
|
280
303
|
liveEvents.next({
|
|
@@ -289,7 +312,7 @@ describe('queryStore', () => {
|
|
|
289
312
|
tags: mockSyncTags,
|
|
290
313
|
})
|
|
291
314
|
|
|
292
|
-
await
|
|
315
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
293
316
|
expect(fetch).toHaveBeenCalledTimes(3)
|
|
294
317
|
expect(vi.mocked(fetch).mock.calls[1][2]?.lastLiveEventId).toBe('event1')
|
|
295
318
|
expect(vi.mocked(fetch).mock.calls[2][2]?.lastLiveEventId).toBe('event2')
|
|
@@ -317,19 +340,190 @@ describe('queryStore', () => {
|
|
|
317
340
|
unsubscribe()
|
|
318
341
|
})
|
|
319
342
|
|
|
343
|
+
it('refetches when a query key is re-added after an error', async () => {
|
|
344
|
+
// First fetch fails (e.g. transient network failure)
|
|
345
|
+
vi.mocked(fetch).mockReturnValueOnce(
|
|
346
|
+
new Observable((observer) => {
|
|
347
|
+
observer.error(new Error('transient network failure'))
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
const query = '*[_type == "movie"]'
|
|
352
|
+
const state1 = getQueryState(instance, {query})
|
|
353
|
+
const unsub1 = state1.subscribe()
|
|
354
|
+
expect(() => state1.getCurrent()).toThrow('transient network failure')
|
|
355
|
+
unsub1()
|
|
356
|
+
|
|
357
|
+
// Wait for the clear delay so the key is fully removed from state
|
|
358
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
359
|
+
|
|
360
|
+
// Retry with the same key — must trigger a fresh fetch
|
|
361
|
+
const state2 = getQueryState(instance, {query})
|
|
362
|
+
const unsub2 = state2.subscribe()
|
|
363
|
+
|
|
364
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
365
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2)
|
|
366
|
+
|
|
367
|
+
const result = await advanceAndAwait(
|
|
368
|
+
firstValueFrom(state2.observable.pipe(filter((i) => i !== undefined))),
|
|
369
|
+
)
|
|
370
|
+
expect(result).toEqual(mockData.movies)
|
|
371
|
+
unsub2()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('releases an errored key created by resolveQuery so a retry can refetch', async () => {
|
|
375
|
+
vi.mocked(fetch).mockReturnValueOnce(
|
|
376
|
+
new Observable((observer) => {
|
|
377
|
+
observer.error(new Error('network down'))
|
|
378
|
+
}),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const query = '*[_type == "movie"]'
|
|
382
|
+
// This is how React drives a suspended query: resolveQuery creates the
|
|
383
|
+
// key without any subscriber (the component never commits when it throws)
|
|
384
|
+
await expect(resolveQuery(instance, {query})).rejects.toThrow('network down')
|
|
385
|
+
|
|
386
|
+
// While the errored key exists, the error surfaces to error boundaries
|
|
387
|
+
const state = getQueryState(instance, {query})
|
|
388
|
+
expect(() => state.getCurrent()).toThrow('network down')
|
|
389
|
+
|
|
390
|
+
// After the clear delay, the errored key must be released — otherwise it
|
|
391
|
+
// has no subscribers, nothing ever removes it, and every future mount
|
|
392
|
+
// rethrows the stored error without ever fetching
|
|
393
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
394
|
+
expect(state.getCurrent()).toBeUndefined()
|
|
395
|
+
|
|
396
|
+
// A retry now creates a fresh key and fetches
|
|
397
|
+
await expect(advanceAndAwait(resolveQuery(instance, {query}))).resolves.toEqual(mockData.movies)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('stops live updates without retrying or erroring on a 4xx connection rejection', async () => {
|
|
401
|
+
const query = '*[_type == "movie"]'
|
|
402
|
+
const state = getQueryState(instance, {query})
|
|
403
|
+
const unsub = state.subscribe()
|
|
404
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
405
|
+
|
|
406
|
+
const clientState = vi.mocked(getClientState).mock.results[0]
|
|
407
|
+
?.value as StateSource<SanityClient>
|
|
408
|
+
const client = await firstValueFrom(clientState.observable)
|
|
409
|
+
const eventsMock = vi.mocked(client.live.events)
|
|
410
|
+
const callsBefore = eventsMock.mock.calls.length
|
|
411
|
+
|
|
412
|
+
// The server rejected the connection with a 401 (e.g. expired token). The
|
|
413
|
+
// client surfaces this as a fatal ConnectionFailedError with the status —
|
|
414
|
+
// retrying would reconnect once per second forever against a server that
|
|
415
|
+
// keeps rejecting
|
|
416
|
+
liveEvents.error(new ConnectionFailedError('EventSource connection failed', {status: 401}))
|
|
417
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
418
|
+
|
|
419
|
+
expect(() => state.getCurrent()).not.toThrow()
|
|
420
|
+
expect(eventsMock.mock.calls.length).toBe(callsBefore)
|
|
421
|
+
unsub()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('retries a connection failure without a status (transient network failure)', async () => {
|
|
425
|
+
const query = '*[_type == "movie"]'
|
|
426
|
+
const state = getQueryState(instance, {query})
|
|
427
|
+
const unsub = state.subscribe()
|
|
428
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
429
|
+
|
|
430
|
+
const clientState = vi.mocked(getClientState).mock.results[0]
|
|
431
|
+
?.value as StateSource<SanityClient>
|
|
432
|
+
const client = await firstValueFrom(clientState.observable)
|
|
433
|
+
const eventsMock = vi.mocked(client.live.events)
|
|
434
|
+
const callsBefore = eventsMock.mock.calls.length
|
|
435
|
+
|
|
436
|
+
// No status means the failure could be transient (native EventSource
|
|
437
|
+
// exposes no status) — reconnecting is correct here
|
|
438
|
+
liveEvents.error(new ConnectionFailedError('EventSource connection failed'))
|
|
439
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 2)
|
|
440
|
+
|
|
441
|
+
expect(() => state.getCurrent()).not.toThrow()
|
|
442
|
+
expect(eventsMock.mock.calls.length).toBeGreaterThan(callsBefore)
|
|
443
|
+
unsub()
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('surfaces CORS errors on the live connection as a store-wide error', async () => {
|
|
447
|
+
const query = '*[_type == "movie"]'
|
|
448
|
+
const state = getQueryState(instance, {query})
|
|
449
|
+
const unsub = state.subscribe()
|
|
450
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
451
|
+
|
|
452
|
+
liveEvents.error(new CorsOriginError({projectId: 'test'}))
|
|
453
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
454
|
+
|
|
455
|
+
// the swallowed CORS error is recorded as store state so the query
|
|
456
|
+
// selector rethrows it (handled by the Cors Error component)
|
|
457
|
+
expect(() => state.getCurrent()).toThrow(CorsOriginError)
|
|
458
|
+
unsub()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('stops live updates without retrying or erroring on DisconnectError', async () => {
|
|
462
|
+
const query = '*[_type == "movie"]'
|
|
463
|
+
const state = getQueryState(instance, {query})
|
|
464
|
+
const unsub = state.subscribe()
|
|
465
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
466
|
+
|
|
467
|
+
// Grab the live events factory from the client the store is subscribed to
|
|
468
|
+
const clientState = vi.mocked(getClientState).mock.results[0]
|
|
469
|
+
?.value as StateSource<SanityClient>
|
|
470
|
+
const client = await firstValueFrom(clientState.observable)
|
|
471
|
+
const eventsMock = vi.mocked(client.live.events)
|
|
472
|
+
const callsBefore = eventsMock.mock.calls.length
|
|
473
|
+
|
|
474
|
+
// The server instructed the client to stop reconnecting
|
|
475
|
+
liveEvents.error(new DisconnectError('Server disconnected client'))
|
|
476
|
+
// Advance past several retry delays
|
|
477
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
478
|
+
|
|
479
|
+
// No store-wide error, and no reconnect attempts (a retry would call
|
|
480
|
+
// live.events again)
|
|
481
|
+
expect(() => state.getCurrent()).not.toThrow()
|
|
482
|
+
expect(eventsMock.mock.calls.length).toBe(callsBefore)
|
|
483
|
+
unsub()
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('keeps queries working after the live events connection errors', async () => {
|
|
487
|
+
const query = '*[_type == "movie"]'
|
|
488
|
+
const state = getQueryState(instance, {query})
|
|
489
|
+
const unsub = state.subscribe()
|
|
490
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
491
|
+
|
|
492
|
+
// The live connection drops (e.g. network goes offline)
|
|
493
|
+
liveEvents.error(new Error('live connection lost'))
|
|
494
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
495
|
+
|
|
496
|
+
// Existing query state must remain readable — a lost live connection must
|
|
497
|
+
// not poison the whole store
|
|
498
|
+
expect(() => state.getCurrent()).not.toThrow()
|
|
499
|
+
expect(state.getCurrent()).toEqual(mockData.movies)
|
|
500
|
+
unsub()
|
|
501
|
+
|
|
502
|
+
// Wait for the clear delay so the key is fully removed, then re-add it —
|
|
503
|
+
// fetching must still work
|
|
504
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
505
|
+
const state2 = getQueryState(instance, {query})
|
|
506
|
+
const unsub2 = state2.subscribe()
|
|
507
|
+
const result = await advanceAndAwait(
|
|
508
|
+
firstValueFrom(state2.observable.pipe(filter((i) => i !== undefined))),
|
|
509
|
+
)
|
|
510
|
+
expect(result).toEqual(mockData.movies)
|
|
511
|
+
unsub2()
|
|
512
|
+
})
|
|
513
|
+
|
|
320
514
|
it('delays query state removal after unsubscribe', async () => {
|
|
321
515
|
const query = '*[_type == "movie"]'
|
|
322
516
|
const state = getQueryState(instance, {query})
|
|
323
517
|
const unsubscribe = state.subscribe()
|
|
324
518
|
|
|
325
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
519
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
326
520
|
|
|
327
521
|
unsubscribe()
|
|
328
522
|
// Immediately after unsubscription, state should still be present due to delay
|
|
329
523
|
expect(state.getCurrent()).not.toBeUndefined()
|
|
330
524
|
|
|
331
525
|
// Wait for the cleanup delay and then state should be removed
|
|
332
|
-
await
|
|
526
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
333
527
|
expect(state.getCurrent()).toBeUndefined()
|
|
334
528
|
})
|
|
335
529
|
|
|
@@ -338,7 +532,7 @@ describe('queryStore', () => {
|
|
|
338
532
|
const state = getQueryState(instance, {query})
|
|
339
533
|
const unsubscribe1 = state.subscribe()
|
|
340
534
|
|
|
341
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
535
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
342
536
|
expect(state.getCurrent()).toEqual([
|
|
343
537
|
{_id: 'movie1', _type: 'movie', title: 'Movie 1'},
|
|
344
538
|
{_id: 'movie2', _type: 'movie', title: 'Movie 2'},
|
|
@@ -346,13 +540,13 @@ describe('queryStore', () => {
|
|
|
346
540
|
|
|
347
541
|
unsubscribe1()
|
|
348
542
|
// Wait less than the cleanup delay
|
|
349
|
-
await
|
|
543
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY / 2)
|
|
350
544
|
|
|
351
545
|
// Subscribe again before cleanup occurs
|
|
352
546
|
const unsubscribe2 = state.subscribe()
|
|
353
547
|
|
|
354
548
|
// Wait for cleanup delay to pass
|
|
355
|
-
await
|
|
549
|
+
await vi.advanceTimersByTimeAsync(QUERY_STATE_CLEAR_DELAY)
|
|
356
550
|
|
|
357
551
|
// Since a subscriber now exists, state should still be present
|
|
358
552
|
expect(state.getCurrent()).toEqual([
|
|
@@ -392,11 +586,11 @@ describe('queryStore', () => {
|
|
|
392
586
|
const unsubDrafts = sDrafts.subscribe()
|
|
393
587
|
const unsubPublished = sPublished.subscribe()
|
|
394
588
|
|
|
395
|
-
const draftsResult = await
|
|
396
|
-
sDrafts.observable.pipe(filter((i) => i !== undefined)),
|
|
589
|
+
const draftsResult = await advanceAndAwait(
|
|
590
|
+
firstValueFrom(sDrafts.observable.pipe(filter((i) => i !== undefined))),
|
|
397
591
|
)
|
|
398
|
-
const publishedResult = await
|
|
399
|
-
sPublished.observable.pipe(filter((i) => i !== undefined)),
|
|
592
|
+
const publishedResult = await advanceAndAwait(
|
|
593
|
+
firstValueFrom(sPublished.observable.pipe(filter((i) => i !== undefined))),
|
|
400
594
|
)
|
|
401
595
|
|
|
402
596
|
expect(draftsResult).toEqual([{_id: 'drafts'}])
|
|
@@ -432,11 +626,11 @@ describe('queryStore', () => {
|
|
|
432
626
|
const unsubDrafts = sDrafts.subscribe()
|
|
433
627
|
const unsubPublished = sPublished.subscribe()
|
|
434
628
|
|
|
435
|
-
const draftsResult = await
|
|
436
|
-
sDrafts.observable.pipe(filter((i) => i !== undefined)),
|
|
629
|
+
const draftsResult = await advanceAndAwait(
|
|
630
|
+
firstValueFrom(sDrafts.observable.pipe(filter((i) => i !== undefined))),
|
|
437
631
|
)
|
|
438
|
-
const publishedResult = await
|
|
439
|
-
sPublished.observable.pipe(filter((i) => i !== undefined)),
|
|
632
|
+
const publishedResult = await advanceAndAwait(
|
|
633
|
+
firstValueFrom(sPublished.observable.pipe(filter((i) => i !== undefined))),
|
|
440
634
|
)
|
|
441
635
|
|
|
442
636
|
expect(draftsResult).toEqual([{_id: 'drafts'}])
|
|
@@ -455,7 +649,7 @@ describe('queryStore', () => {
|
|
|
455
649
|
const state = getQueryState(instance, {query, resource: mediaLibrarySource})
|
|
456
650
|
const unsubscribe = state.subscribe()
|
|
457
651
|
|
|
458
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
652
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
459
653
|
|
|
460
654
|
// Verify getClientState was called with the resource from params in listenForNewSubscribersAndFetch
|
|
461
655
|
// This call includes projectId, dataset, and resource
|
|
@@ -478,7 +672,7 @@ describe('queryStore', () => {
|
|
|
478
672
|
const state = getQueryState(instance, {query, resource: canvasSource})
|
|
479
673
|
const unsubscribe = state.subscribe()
|
|
480
674
|
|
|
481
|
-
await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
|
|
675
|
+
await advanceAndAwait(firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))))
|
|
482
676
|
|
|
483
677
|
// Verify getClientState was called with the canvas resource for live events
|
|
484
678
|
// The resource is extracted from the store key and passed when it's not a dataset resource
|
package/src/query/queryStore.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {type ResponseQueryOptions} from '@sanity/client'
|
|
2
2
|
import {type SanityQueryResult} from 'groq'
|
|
3
3
|
import {
|
|
4
4
|
catchError,
|
|
5
5
|
combineLatest,
|
|
6
|
-
defer,
|
|
7
6
|
distinctUntilChanged,
|
|
8
7
|
EMPTY,
|
|
9
8
|
filter,
|
|
@@ -24,15 +23,8 @@ import {
|
|
|
24
23
|
} from 'rxjs'
|
|
25
24
|
|
|
26
25
|
import {getClientState} from '../client/clientStore'
|
|
26
|
+
import {observeLiveEvents} from '../client/liveEvents'
|
|
27
27
|
import {type DatasetHandle} from '../config/sanityConfig'
|
|
28
|
-
/*
|
|
29
|
-
* Although this is an import dependency cycle, it is not a logical cycle:
|
|
30
|
-
* 1. queryStore uses getPerspectiveState when resolving release perspectives
|
|
31
|
-
* 2. getPerspectiveState uses releasesStore as a data source
|
|
32
|
-
* 3. releasesStore uses queryStore as a data source
|
|
33
|
-
* 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
|
|
34
|
-
*/
|
|
35
|
-
// eslint-disable-next-line import/no-cycle
|
|
36
28
|
import {getPerspectiveState} from '../releases/getPerspectiveState'
|
|
37
29
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
38
30
|
import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
|
|
@@ -53,8 +45,6 @@ import {
|
|
|
53
45
|
} from './queryStoreConstants'
|
|
54
46
|
import {
|
|
55
47
|
addSubscriber,
|
|
56
|
-
cancelQuery,
|
|
57
|
-
initializeQuery,
|
|
58
48
|
type QueryStoreState,
|
|
59
49
|
removeSubscriber,
|
|
60
50
|
setLastLiveEventId,
|
|
@@ -183,6 +173,7 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
|
|
|
183
173
|
const perspective$ = isReleasePerspective(perspectiveFromOptions)
|
|
184
174
|
? getPerspectiveState(instance, {
|
|
185
175
|
perspective: perspectiveFromOptions,
|
|
176
|
+
resource,
|
|
186
177
|
}).observable.pipe(filter(Boolean))
|
|
187
178
|
: of(perspectiveFromOptions ?? QUERY_STORE_DEFAULT_PERSPECTIVE)
|
|
188
179
|
|
|
@@ -208,15 +199,19 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
|
|
|
208
199
|
tag,
|
|
209
200
|
}),
|
|
210
201
|
),
|
|
202
|
+
tap(({result, syncTags}) => {
|
|
203
|
+
state.set('setQueryData', setQueryData(group$.key, result, syncTags))
|
|
204
|
+
}),
|
|
205
|
+
// Catch inside the per-event stream: erroring the group pipe would
|
|
206
|
+
// complete the group's subscription, and since `groupBy` above never
|
|
207
|
+
// removes its group subjects, re-adding the key would emit into a
|
|
208
|
+
// subject with no subscribers — the key could never be fetched again.
|
|
209
|
+
catchError((error) => {
|
|
210
|
+
state.set('setQueryError', setQueryError(group$.key, error))
|
|
211
|
+
return EMPTY
|
|
212
|
+
}),
|
|
211
213
|
)
|
|
212
214
|
}),
|
|
213
|
-
catchError((error) => {
|
|
214
|
-
state.set('setQueryError', setQueryError(group$.key, error))
|
|
215
|
-
return EMPTY
|
|
216
|
-
}),
|
|
217
|
-
tap(({result, syncTags}) => {
|
|
218
|
-
state.set('setQueryData', setQueryData(group$.key, result, syncTags))
|
|
219
|
-
}),
|
|
220
215
|
),
|
|
221
216
|
),
|
|
222
217
|
)
|
|
@@ -228,27 +223,14 @@ const listenToLiveClientAndSetLastLiveEventIds = ({
|
|
|
228
223
|
instance,
|
|
229
224
|
key: {resource},
|
|
230
225
|
}: StoreContext<QueryStoreState, BoundResourceKey>) => {
|
|
231
|
-
const liveMessages$ =
|
|
232
|
-
apiVersion: QUERY_STORE_API_VERSION,
|
|
226
|
+
const liveMessages$ = observeLiveEvents(instance, {
|
|
233
227
|
resource,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (error instanceof CorsOriginError) {
|
|
241
|
-
// Swallow only CORS errors in store without bubbling up so that they are handled by the Cors Error component
|
|
242
|
-
state.set('setError', {error})
|
|
243
|
-
return EMPTY
|
|
244
|
-
}
|
|
245
|
-
throw error
|
|
246
|
-
}),
|
|
247
|
-
),
|
|
248
|
-
),
|
|
249
|
-
share(),
|
|
250
|
-
filter((e) => e.type === 'message'),
|
|
251
|
-
)
|
|
228
|
+
// Surface CORS errors as store state (handled by the Cors Error
|
|
229
|
+
// component) instead of erroring the stream: a stream error here would
|
|
230
|
+
// reach the outer subscription's errorHandler, set the store-wide
|
|
231
|
+
// `state.error`, and permanently brick every query selector.
|
|
232
|
+
onCorsError: (error) => state.set('setError', {error}),
|
|
233
|
+
}).pipe(share())
|
|
252
234
|
|
|
253
235
|
return state.observable
|
|
254
236
|
.pipe(
|
|
@@ -346,13 +328,14 @@ const _getQueryState = bindActionByResource(
|
|
|
346
328
|
* Resolves the result of a query without registering a lasting subscriber.
|
|
347
329
|
*
|
|
348
330
|
* This function fetches the result of a GROQ query and returns a promise that resolves with the query result.
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
331
|
+
* It registers a temporary subscriber for the lifetime of the resolution (released shortly after the promise
|
|
332
|
+
* settles), so the query state participates in normal cleanup even when no lasting subscriber ever attaches —
|
|
333
|
+
* e.g. when a suspended component errors before committing. This makes it ideal for use with React Suspense,
|
|
334
|
+
* where the returned promise is thrown to delay rendering until the query result becomes available.
|
|
352
335
|
* Once the promise resolves, it is expected that a real subscriber will be added via `getQueryState` to manage ongoing updates.
|
|
353
336
|
*
|
|
354
337
|
* Additionally, an optional AbortSignal can be provided to cancel the query and immediately clear the associated state
|
|
355
|
-
* if there are no active subscribers.
|
|
338
|
+
* if there are no other active subscribers.
|
|
356
339
|
*
|
|
357
340
|
* @beta
|
|
358
341
|
*/
|
|
@@ -381,6 +364,14 @@ const _resolveQuery = bindActionByResource(
|
|
|
381
364
|
const {getCurrent} = getQueryState(instance, normalized)
|
|
382
365
|
const key = getQueryKey(normalized)
|
|
383
366
|
|
|
367
|
+
// Hold a temporary subscription for the lifetime of this resolution so the
|
|
368
|
+
// key participates in normal cleanup. Without one, a fetch that errors
|
|
369
|
+
// while a component is suspended leaves a subscriber-less key behind — the
|
|
370
|
+
// component never commits, so no other subscriber exists — and its stored
|
|
371
|
+
// error would be rethrown on every future mount without ever refetching.
|
|
372
|
+
const subscriptionId = insecureRandomId()
|
|
373
|
+
state.set('addSubscriber', addSubscriber(key, subscriptionId))
|
|
374
|
+
|
|
384
375
|
const aborted$ = signal
|
|
385
376
|
? new Observable<void>((observer) => {
|
|
386
377
|
const cleanup = () => {
|
|
@@ -398,20 +389,29 @@ const _resolveQuery = bindActionByResource(
|
|
|
398
389
|
}).pipe(
|
|
399
390
|
catchError((error) => {
|
|
400
391
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
401
|
-
|
|
392
|
+
// Release immediately (not after the clear delay) so that when
|
|
393
|
+
// this was the only subscriber, the key removal tears down the
|
|
394
|
+
// in-flight request right away — e.g. when useQuery switches
|
|
395
|
+
// to a different query
|
|
396
|
+
state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
|
|
402
397
|
}
|
|
403
398
|
throw error
|
|
404
399
|
}),
|
|
405
400
|
)
|
|
406
401
|
: NEVER
|
|
407
402
|
|
|
408
|
-
state.set('initializeQuery', initializeQuery(key))
|
|
409
|
-
|
|
410
403
|
const resolved$ = state.observable.pipe(
|
|
411
404
|
map(getCurrent),
|
|
412
405
|
first((i) => i !== undefined),
|
|
413
406
|
)
|
|
414
407
|
|
|
415
|
-
|
|
408
|
+
const promise = firstValueFrom(race([resolved$, aborted$]))
|
|
409
|
+
const releaseSubscriber = () => {
|
|
410
|
+
setCleanupTimeout(() => {
|
|
411
|
+
state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
|
|
412
|
+
}, QUERY_STATE_CLEAR_DELAY)
|
|
413
|
+
}
|
|
414
|
+
promise.then(releaseSubscriber, releaseSubscriber)
|
|
415
|
+
return promise
|
|
416
416
|
},
|
|
417
417
|
)
|