@sanity/sdk 2.15.0 → 2.17.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.
Files changed (41) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
  4. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  5. package/dist/_chunks-es/version.js +1 -1
  6. package/dist/index.d.ts +69 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +79 -32
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -12
  11. package/src/_exports/index.ts +2 -0
  12. package/src/auth/authStore.test.ts +1 -1
  13. package/src/auth/handleAuthCallback.test.ts +1 -1
  14. package/src/auth/refreshStampedToken.test.ts +199 -477
  15. package/src/auth/refreshStampedToken.ts +67 -195
  16. package/src/client/liveEvents.test.ts +151 -0
  17. package/src/client/liveEvents.ts +107 -0
  18. package/src/document/actions.ts +36 -0
  19. package/src/document/documentStore.test.ts +132 -1
  20. package/src/document/documentStore.ts +8 -0
  21. package/src/document/events.ts +32 -0
  22. package/src/document/listen.ts +8 -0
  23. package/src/document/processActions/edit.ts +19 -14
  24. package/src/document/processActions/shared.ts +55 -8
  25. package/src/document/processActions.test.ts +145 -0
  26. package/src/document/reducers.test.ts +396 -0
  27. package/src/document/reducers.ts +62 -1
  28. package/src/document/sharedListener.test.ts +1 -0
  29. package/src/document/sharedListener.ts +2 -0
  30. package/src/query/queryStore.test.ts +232 -38
  31. package/src/query/queryStore.ts +47 -47
  32. package/src/query/reducers.test.ts +0 -42
  33. package/src/query/reducers.ts +0 -16
  34. package/src/releases/getPerspectiveState.test.ts +3 -8
  35. package/src/releases/getPerspectiveState.ts +6 -19
  36. package/src/releases/observeReleases.test.ts +127 -0
  37. package/src/releases/observeReleases.ts +102 -0
  38. package/src/releases/releasesStore.test.ts +51 -53
  39. package/src/releases/releasesStore.ts +32 -32
  40. package/src/releases/utils/sortReleases.test.ts +36 -0
  41. package/src/releases/utils/sortReleases.ts +14 -9
@@ -1,17 +1,21 @@
1
- import {type LiveEvent, type SanityClient, type SyncTag} from '@sanity/client'
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 new Promise((resolve) => setTimeout(resolve, 20))
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 new Promise((resolve) => setTimeout(resolve, 20))
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
- // Use resolveQuery which should not add a subscriber
155
- const result = await resolveQuery(instance, {query})
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 starts resolved now.
162
- // Note that this behavior is important for supporting suspense.
163
- // `resolveQuery` is the only way to resolve state without adding a
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 new Promise((resolve) => setTimeout(resolve, 20))
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 firstValueFrom(state.observable.pipe(filter((data) => data?.length === 3)))
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 new Promise((resolve) => setTimeout(resolve, 50)) // Allow time for potential refetch
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 new Promise((resolve) => setTimeout(resolve, 0))
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 new Promise((resolve) => setTimeout(resolve, 20))
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 new Promise((resolve) => setTimeout(resolve, 5))
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 new Promise((resolve) => setTimeout(resolve, 20))
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 firstValueFrom(
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 firstValueFrom(
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 firstValueFrom(
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 firstValueFrom(
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
@@ -1,9 +1,8 @@
1
- import {CorsOriginError, type ResponseQueryOptions} from '@sanity/client'
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$ = getClientState(instance, {
232
- apiVersion: QUERY_STORE_API_VERSION,
226
+ const liveMessages$ = observeLiveEvents(instance, {
233
227
  resource,
234
- }).observable.pipe(
235
- switchMap((client) =>
236
- defer(() =>
237
- client.live.events({includeDrafts: !!client.config().token, tag: 'query-store'}),
238
- ).pipe(
239
- catchError((error) => {
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
- * Unlike `getQueryState`, which registers subscribers to keep the query live and performs automatic cleanup,
350
- * `resolveQuery` does not track subscribers. This makes it ideal for use with React Suspense, where the returned
351
- * promise is thrown to delay rendering until the query result becomes available.
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
- state.set('cancelQuery', cancelQuery(key))
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
- return firstValueFrom(race([resolved$, aborted$]))
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
  )