sanity 3.72.1 → 3.72.2-use-live-content-api.9

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 (48) hide show
  1. package/lib/_chunks-cjs/LiveQueries.js +236 -163
  2. package/lib/_chunks-cjs/LiveQueries.js.map +1 -1
  3. package/lib/_chunks-cjs/PresentationToolGrantsCheck.js +3 -7
  4. package/lib/_chunks-cjs/PresentationToolGrantsCheck.js.map +1 -1
  5. package/lib/_chunks-cjs/buildAction.js +3 -0
  6. package/lib/_chunks-cjs/buildAction.js.map +1 -1
  7. package/lib/_chunks-cjs/presentation.js +3 -4
  8. package/lib/_chunks-cjs/presentation.js.map +1 -1
  9. package/lib/_chunks-cjs/version.js +1 -1
  10. package/lib/_chunks-es/LiveQueries.mjs +235 -162
  11. package/lib/_chunks-es/LiveQueries.mjs.map +1 -1
  12. package/lib/_chunks-es/PresentationToolGrantsCheck.mjs +2 -4
  13. package/lib/_chunks-es/PresentationToolGrantsCheck.mjs.map +1 -1
  14. package/lib/_chunks-es/presentation.mjs +3 -3
  15. package/lib/_chunks-es/presentation.mjs.map +1 -1
  16. package/lib/_chunks-es/version.mjs +1 -1
  17. package/lib/_internal/cli/threads/validateDocuments.js +1 -1
  18. package/lib/_internal/cli/threads/validateDocuments.js.map +1 -1
  19. package/lib/_legacy/LiveQueries.esm.js +235 -162
  20. package/lib/_legacy/LiveQueries.esm.js.map +1 -1
  21. package/lib/_legacy/PresentationToolGrantsCheck.esm.js +2 -4
  22. package/lib/_legacy/PresentationToolGrantsCheck.esm.js.map +1 -1
  23. package/lib/_legacy/presentation.esm.js +3 -3
  24. package/lib/_legacy/presentation.esm.js.map +1 -1
  25. package/lib/_legacy/version.esm.js +1 -1
  26. package/package.json +13 -14
  27. package/src/_internal/cli/server/buildVendorDependencies.ts +1 -0
  28. package/src/_internal/cli/threads/validateDocuments.ts +2 -2
  29. package/src/presentation/PresentationTool.tsx +10 -21
  30. package/src/presentation/constants.ts +4 -16
  31. package/src/presentation/loader/LiveQueries.tsx +79 -234
  32. package/src/presentation/loader/useLiveEvents.ts +75 -0
  33. package/src/presentation/loader/useLiveQueries.ts +127 -0
  34. package/src/presentation/loader/utils.ts +2 -125
  35. package/src/presentation/types.ts +1 -18
  36. package/lib/_chunks-cjs/LoaderQueries.js +0 -281
  37. package/lib/_chunks-cjs/LoaderQueries.js.map +0 -1
  38. package/lib/_chunks-cjs/utils.js +0 -70
  39. package/lib/_chunks-cjs/utils.js.map +0 -1
  40. package/lib/_chunks-es/LoaderQueries.mjs +0 -288
  41. package/lib/_chunks-es/LoaderQueries.mjs.map +0 -1
  42. package/lib/_chunks-es/utils.mjs +0 -74
  43. package/lib/_chunks-es/utils.mjs.map +0 -1
  44. package/lib/_legacy/LoaderQueries.esm.js +0 -288
  45. package/lib/_legacy/LoaderQueries.esm.js.map +0 -1
  46. package/lib/_legacy/utils.esm.js +0 -74
  47. package/lib/_legacy/utils.esm.js.map +0 -1
  48. package/src/presentation/loader/LoaderQueries.tsx +0 -564
@@ -1,8 +1,6 @@
1
- /* eslint-disable @typescript-eslint/no-shadow */
2
1
  import {
3
2
  type ClientPerspective,
4
3
  type ContentSourceMap,
5
- createClient,
6
4
  type LiveEventMessage,
7
5
  type QueryParams,
8
6
  type SyncTag,
@@ -20,30 +18,18 @@ import {
20
18
  type LoaderNodeMsg,
21
19
  } from '@sanity/presentation-comlink'
22
20
  import isEqual from 'fast-deep-equal'
23
- // import {createPreviewSecret} from '@sanity/preview-url-secret/create-secret'
24
- import {memo, useDeferredValue, useEffect, useMemo, useState} from 'react'
25
- import {
26
- type SanityClient,
27
- type SanityDocument,
28
- useClient,
29
- // useCurrentUser,
30
- useDataset,
31
- useProjectId,
32
- } from 'sanity'
21
+ import {memo, startTransition, useDeferredValue, useEffect, useMemo, useState} from 'react'
22
+ import {type SanityClient, type SanityDocument, useClient, useDataset, useProjectId} from 'sanity'
33
23
  import {useEffectEvent} from 'use-effect-event'
34
24
 
35
- // import {useEffectEvent} from 'use-effect-event'
36
- import {MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL} from '../constants'
37
- import {
38
- type LiveQueriesState,
39
- type LiveQueriesStateValue,
40
- type LoaderConnection,
41
- type PresentationPerspective,
42
- } from '../types'
25
+ import {API_VERSION, MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL} from '../constants'
26
+ import {type LoaderConnection, type PresentationPerspective} from '../types'
43
27
  import {type DocumentOnPage} from '../useDocumentsOnPage'
44
- import {mapChangedValue, useQueryParams, useRevalidate} from './utils'
28
+ import {useLiveEvents} from './useLiveEvents'
29
+ import {useLiveQueries} from './useLiveQueries'
30
+ import {mapChangedValue} from './utils'
45
31
 
46
- export interface LoaderQueriesProps {
32
+ export interface LiveQueriesProps {
47
33
  liveDocument: Partial<SanityDocument> | null | undefined
48
34
  controller: Controller | undefined
49
35
  perspective: ClientPerspective
@@ -55,55 +41,18 @@ export interface LoaderQueriesProps {
55
41
  ) => void
56
42
  }
57
43
 
58
- export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Element {
59
- const {
60
- liveDocument: _liveDocument,
61
- controller,
62
- perspective: activePerspective,
63
- onLoadersConnection,
64
- onDocumentsOnPage,
65
- } = props
44
+ export default function LiveQueries(props: LiveQueriesProps): React.JSX.Element {
45
+ const {controller, perspective: activePerspective, onLoadersConnection, onDocumentsOnPage} = props
66
46
 
67
47
  const [comlink, setComlink] = useState<ChannelInstance<LoaderControllerMsg, LoaderNodeMsg>>()
68
- const [liveQueries, setLiveQueries] = useState<LiveQueriesState>({})
48
+ const [liveQueries, liveQueriesDispatch] = useLiveQueries()
69
49
 
70
50
  const projectId = useProjectId()
71
51
  const dataset = useDataset()
72
52
 
73
- useEffect(() => {
74
- const interval = setInterval(
75
- () =>
76
- setLiveQueries((liveQueries) => {
77
- if (Object.keys(liveQueries).length < 1) {
78
- return liveQueries
79
- }
80
-
81
- const now = Date.now()
82
- const hasAnyExpired = Object.values(liveQueries).some(
83
- // eslint-disable-next-line max-nested-callbacks
84
- (liveQuery) =>
85
- liveQuery.heartbeat !== false && now > liveQuery.receivedAt + liveQuery.heartbeat,
86
- )
87
- if (!hasAnyExpired) {
88
- return liveQueries
89
- }
90
- const next = {} as LiveQueriesState
91
- for (const [key, value] of Object.entries(liveQueries)) {
92
- if (value.heartbeat !== false && now > value.receivedAt + value.heartbeat) {
93
- continue
94
- }
95
- next[key] = value
96
- }
97
- return next
98
- }),
99
- MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL,
100
- )
101
- return () => clearInterval(interval)
102
- }, [])
103
-
104
- useEffect(() => {
53
+ useEffect((): (() => void) => {
105
54
  if (controller) {
106
- const comlink = controller.createChannel<LoaderControllerMsg, LoaderNodeMsg>(
55
+ const nextComlink = controller.createChannel<LoaderControllerMsg, LoaderNodeMsg>(
107
56
  {
108
57
  name: 'presentation',
109
58
  connectTo: 'loaders',
@@ -113,11 +62,11 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
113
62
  actors: createCompatibilityActors<LoaderControllerMsg>(),
114
63
  }),
115
64
  )
116
- setComlink(comlink)
65
+ setComlink(nextComlink)
117
66
 
118
- comlink.onStatus(onLoadersConnection)
67
+ nextComlink.onStatus(onLoadersConnection)
119
68
 
120
- comlink.on('loader/documents', (data) => {
69
+ nextComlink.on('loader/documents', (data) => {
121
70
  if (data.projectId === projectId && data.dataset === dataset) {
122
71
  onDocumentsOnPage(
123
72
  'loaders',
@@ -128,7 +77,7 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
128
77
  }
129
78
  })
130
79
 
131
- comlink.on('loader/query-listen', (data) => {
80
+ nextComlink.on('loader/query-listen', (data) => {
132
81
  if (data.projectId === projectId && data.dataset === dataset) {
133
82
  if (
134
83
  typeof data.heartbeat === 'number' &&
@@ -138,54 +87,24 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
138
87
  `Loader query listen heartbeat interval must be at least ${MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL}ms`,
139
88
  )
140
89
  }
141
- setLiveQueries((prev) => ({
142
- ...prev,
143
- [getQueryCacheKey(data.query, data.params)]: {
90
+ liveQueriesDispatch({
91
+ type: 'query-listen',
92
+ payload: {
144
93
  perspective: data.perspective,
145
94
  query: data.query,
146
95
  params: data.params,
147
- receivedAt: Date.now(),
148
96
  heartbeat: data.heartbeat ?? false,
149
- } satisfies LiveQueriesStateValue,
150
- }))
97
+ },
98
+ })
151
99
  }
152
100
  })
153
101
 
154
- return comlink.start()
102
+ return nextComlink.start()
155
103
  }
156
- return undefined
157
- }, [controller, dataset, onDocumentsOnPage, onLoadersConnection, projectId])
158
-
159
- // const currentUser = useCurrentUser()
160
- // const handleCreatePreviewUrlSecret = useEffectEvent(
161
- // async ({projectId, dataset}: {projectId: string; dataset: string}) => {
162
- // try {
163
- // // eslint-disable-next-line no-console
164
- // console.log('Creating preview URL secret for ', {projectId, dataset})
165
- // const {secret} = await createPreviewSecret(
166
- // client,
167
- // '@sanity/presentation',
168
- // typeof window === 'undefined' ? '' : location.href,
169
- // currentUser?.id,
170
- // )
171
- // return {secret}
172
- // } catch (err) {
173
- // // eslint-disable-next-line no-console
174
- // console.error('Failed to generate preview URL secret', err)
175
- // return {secret: null}
176
- // }
177
- // },
178
- // )
179
- // useEffect(() => {
180
- // return comlink?.on('loader/fetch-preview-url-secret', (data) =>
181
- // handleCreatePreviewUrlSecret(data),
182
- // )
183
- // }, [comlink, handleCreatePreviewUrlSecret])
104
+ return () => undefined
105
+ }, [controller, dataset, liveQueriesDispatch, onDocumentsOnPage, onLoadersConnection, projectId])
184
106
 
185
- const [syncTagsInUse] = useState(() => new Set<SyncTag[]>())
186
- const [lastLiveEventId, setLastLiveEventId] = useState<string | null>(null)
187
- const studioClient = useClient({apiVersion: '2023-10-16'})
188
- const clientConfig = useMemo(() => studioClient.config(), [studioClient])
107
+ const studioClient = useClient({apiVersion: API_VERSION})
189
108
  const client = useMemo(
190
109
  () =>
191
110
  studioClient.withConfig({
@@ -195,69 +114,35 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
195
114
  )
196
115
  useEffect(() => {
197
116
  if (comlink) {
198
- // eslint-disable-next-line @typescript-eslint/no-shadow
199
- const {projectId, dataset} = clientConfig
200
117
  comlink.post('loader/perspective', {
201
- projectId: projectId!,
202
- dataset: dataset!,
118
+ projectId,
119
+ dataset,
203
120
  perspective: activePerspective,
204
121
  })
205
122
  }
206
- }, [comlink, clientConfig, activePerspective])
207
-
208
- const handleSyncTags = useEffectEvent((event: LiveEventMessage) => {
209
- const flattenedSyncTags = Array.from(syncTagsInUse).flat()
210
- const hasMatchingTags = event.tags.some((tag) => flattenedSyncTags.includes(tag))
211
- if (hasMatchingTags) {
212
- setLastLiveEventId(event.id)
213
- } else {
214
- // eslint-disable-next-line no-console
215
- console.log('No matching tags found', event.tags, {flattenedSyncTags})
216
- }
217
- })
218
- useEffect(() => {
219
- const liveClient = createClient(client.config()).withConfig({
220
- // Necessary for the live drafts to work
221
- apiVersion: 'vX',
222
- })
223
- const subscription = liveClient.live
224
- .events({includeDrafts: true, tag: 'presentation-loader'})
225
- .subscribe({
226
- next: (event) => {
227
- if (event.type === 'message') {
228
- handleSyncTags(event)
229
- } else if (event.type === 'restart') {
230
- setLastLiveEventId(event.id)
231
- } else if (event.type === 'reconnect') {
232
- setLastLiveEventId(null)
233
- }
234
- },
235
- // eslint-disable-next-line no-console
236
- error: (err) => console.error('Error validating EventSource URL:', err),
237
- })
238
- return () => subscription.unsubscribe()
239
- }, [client, handleSyncTags])
123
+ }, [comlink, activePerspective, projectId, dataset])
240
124
 
241
125
  /**
242
126
  * Defer the liveDocument to avoid unnecessary rerenders on rapid edits
243
127
  */
244
- const liveDocument = useDeferredValue(_liveDocument)
128
+ const liveDocument = useDeferredValue(props.liveDocument)
129
+
130
+ const liveEvents = useLiveEvents(client)
245
131
 
246
132
  return (
247
133
  <>
248
- {Object.entries(liveQueries).map(([key, {query, params, perspective}]) => (
134
+ {[...liveQueries.entries()].map(([key, {query, params, perspective}]) => (
249
135
  <QuerySubscription
250
- key={`${key}${perspective}`}
251
- projectId={clientConfig.projectId!}
252
- dataset={clientConfig.dataset!}
136
+ key={`${liveEvents.resets}:${key}`}
137
+ projectId={projectId}
138
+ dataset={dataset}
253
139
  perspective={perspective}
254
140
  query={query}
255
141
  params={params}
256
142
  comlink={comlink}
257
143
  client={client}
258
144
  liveDocument={liveDocument}
259
- lastLiveEventId={lastLiveEventId}
260
- syncTagsInUse={syncTagsInUse}
145
+ liveEventsMessages={liveEvents.messages}
261
146
  />
262
147
  ))}
263
148
  </>
@@ -272,14 +157,13 @@ interface SharedProps {
272
157
  }
273
158
 
274
159
  interface QuerySubscriptionProps
275
- extends Pick<UseQuerySubscriptionProps, 'client' | 'liveDocument' | 'lastLiveEventId'> {
160
+ extends Pick<UseQuerySubscriptionProps, 'client' | 'liveDocument' | 'liveEventsMessages'> {
276
161
  projectId: string
277
162
  dataset: string
278
163
  perspective: ClientPerspective
279
164
  query: string
280
165
  params: QueryParams
281
166
  comlink: LoaderConnection | undefined
282
- syncTagsInUse: Set<SyncTag[]>
283
167
  }
284
168
  function QuerySubscriptionComponent(props: QuerySubscriptionProps) {
285
169
  const {
@@ -289,12 +173,11 @@ function QuerySubscriptionComponent(props: QuerySubscriptionProps) {
289
173
  query,
290
174
  client,
291
175
  liveDocument,
176
+ params,
292
177
  comlink,
293
- lastLiveEventId,
294
- syncTagsInUse,
178
+ liveEventsMessages,
295
179
  } = props
296
180
 
297
- const params = useQueryParams(props.params)
298
181
  const {
299
182
  result,
300
183
  resultSourceMap,
@@ -305,9 +188,10 @@ function QuerySubscriptionComponent(props: QuerySubscriptionProps) {
305
188
  params,
306
189
  perspective,
307
190
  query,
308
- lastLiveEventId,
191
+ liveEventsMessages,
309
192
  }) || {}
310
193
 
194
+ /* eslint-disable @typescript-eslint/no-shadow,max-params */
311
195
  const handleQueryChange = useEffectEvent(
312
196
  (
313
197
  comlink: LoaderConnection | undefined,
@@ -317,7 +201,6 @@ function QuerySubscriptionComponent(props: QuerySubscriptionProps) {
317
201
  result: unknown,
318
202
  resultSourceMap: ContentSourceMap | undefined,
319
203
  tags: `s1:${string}`[] | undefined,
320
- // eslint-disable-next-line max-params
321
204
  ) => {
322
205
  comlink?.post('loader/query-change', {
323
206
  projectId,
@@ -331,28 +214,14 @@ function QuerySubscriptionComponent(props: QuerySubscriptionProps) {
331
214
  })
332
215
  },
333
216
  )
217
+ /* eslint-enable @typescript-eslint/no-shadow,max-params */
218
+
334
219
  useEffect(() => {
335
220
  if (resultSourceMap) {
336
221
  handleQueryChange(comlink, perspective, query, params, result, resultSourceMap, tags)
337
222
  }
338
- if (Array.isArray(tags)) {
339
- syncTagsInUse.add(tags)
340
- return () => {
341
- syncTagsInUse.delete(tags)
342
- }
343
- }
344
223
  return undefined
345
- }, [
346
- comlink,
347
- handleQueryChange,
348
- params,
349
- perspective,
350
- query,
351
- result,
352
- resultSourceMap,
353
- syncTagsInUse,
354
- tags,
355
- ])
224
+ }, [comlink, handleQueryChange, params, perspective, query, result, resultSourceMap, tags])
356
225
 
357
226
  return null
358
227
  }
@@ -364,79 +233,59 @@ interface UseQuerySubscriptionProps extends Required<Pick<SharedProps, 'client'>
364
233
  query: string
365
234
  params: QueryParams
366
235
  perspective: ClientPerspective
367
- lastLiveEventId: string | null
236
+ liveEventsMessages: LiveEventMessage[]
368
237
  }
369
238
  function useQuerySubscription(props: UseQuerySubscriptionProps) {
370
- const {liveDocument, client, query, params, perspective, lastLiveEventId} = props
371
- const [snapshot, setSnapshot] = useState<{
372
- result: unknown
373
- resultSourceMap?: ContentSourceMap
374
- syncTags?: SyncTag[]
375
- lastLiveEventId: string | null
376
- } | null>(null)
239
+ const {liveDocument, client, query, params, perspective, liveEventsMessages} = props
240
+ const [result, setResult] = useState<unknown>(null)
241
+ const [resultSourceMap, setResultSourceMap] = useState<ContentSourceMap | null | undefined>(null)
242
+ const [syncTags, setSyncTags] = useState<SyncTag[] | undefined>(undefined)
243
+ const [skipEventIds] = useState(() => new Set(liveEventsMessages.map((msg) => msg.id)))
244
+ const recentLiveEvents = liveEventsMessages.filter((msg) => !skipEventIds.has(msg.id))
245
+ const lastLiveEvent = recentLiveEvents.findLast((msg) =>
246
+ msg.tags.some((tag) => syncTags?.includes(tag)),
247
+ )
248
+ const lastLiveEventId = lastLiveEvent?.id
249
+
377
250
  // Make sure any async errors bubble up to the nearest error boundary
378
251
  const [error, setError] = useState<unknown>(null)
379
252
  if (error) throw error
380
253
 
381
- const [revalidate, startRefresh] = useRevalidate({
382
- // Refresh interval is set to zero as we're using the Live Draft Content API to revalidate queries
383
- refreshInterval: 0,
384
- })
385
- const shouldRefetch =
386
- revalidate === 'refresh' ||
387
- revalidate === 'inflight' ||
388
- lastLiveEventId !== snapshot?.lastLiveEventId
254
+ /* eslint-disable max-nested-callbacks */
389
255
  useEffect(() => {
390
- if (!shouldRefetch) {
391
- return undefined
392
- }
393
-
394
- let fulfilled = false
395
- let fetching = false
396
256
  const controller = new AbortController()
397
- // eslint-disable-next-line no-inner-declarations
398
- async function effect() {
399
- const {signal} = controller
400
- fetching = true
401
- const {result, resultSourceMap, syncTags} = await client.fetch(query, params, {
257
+
258
+ client
259
+ .fetch(query, params, {
402
260
  lastLiveEventId,
403
261
  tag: 'presentation-loader',
404
- signal,
262
+ signal: controller.signal,
405
263
  perspective,
406
264
  filterResponse: false,
407
265
  returnQuery: false,
408
266
  })
409
- fetching = false
410
-
411
- if (!signal.aborted) {
412
- setSnapshot((prev) => ({
413
- result: isEqual(prev?.result, result) ? prev?.result : result,
414
- resultSourceMap: isEqual(prev?.resultSourceMap, resultSourceMap)
415
- ? prev?.resultSourceMap
416
- : resultSourceMap,
417
- syncTags: isEqual(prev?.syncTags, syncTags) ? prev?.syncTags : syncTags,
418
- lastLiveEventId,
419
- }))
420
- fulfilled = true
421
- }
422
- }
423
- const onFinally = startRefresh()
424
- effect()
425
- .catch((error) => {
426
- fetching = false
427
- if (error.name !== 'AbortError') {
428
- setError(error)
267
+ .then((response) => {
268
+ startTransition(() => {
269
+ // eslint-disable-next-line max-nested-callbacks
270
+ setResult((prev: unknown) => (isEqual(prev, response.result) ? prev : response.result))
271
+ setResultSourceMap((prev) =>
272
+ isEqual(prev, response.resultSourceMap) ? prev : response.resultSourceMap,
273
+ )
274
+ setSyncTags((prev) => (isEqual(prev, response.syncTags) ? prev : response.syncTags))
275
+ })
276
+ })
277
+ .catch((err) => {
278
+ if (typeof err !== 'object' || err?.name !== 'AbortError') {
279
+ setError(err)
429
280
  }
430
281
  })
431
- .finally(onFinally)
282
+
432
283
  return () => {
433
- if (!fulfilled && !fetching) {
434
- controller.abort()
435
- }
284
+ controller.abort()
436
285
  }
437
- }, [client, lastLiveEventId, params, perspective, query, shouldRefetch, startRefresh])
286
+ }, [client, lastLiveEventId, params, perspective, query])
287
+ /* eslint-enable max-nested-callbacks */
438
288
 
439
- const {result, resultSourceMap, syncTags} = snapshot ?? {}
440
289
  return useMemo(() => {
441
290
  if (liveDocument && resultSourceMap) {
442
291
  return {
@@ -484,7 +333,3 @@ export function turboChargeResultIfSourceMap<T = unknown>(
484
333
  perspective,
485
334
  )
486
335
  }
487
-
488
- function getQueryCacheKey(query: string, params: QueryParams | string): `${string}-${string}` {
489
- return `${query}-${typeof params === 'string' ? params : JSON.stringify(params)}`
490
- }
@@ -0,0 +1,75 @@
1
+ import {type LiveEvent, type LiveEventMessage} from '@sanity/client'
2
+ import {useDeferredValue, useEffect, useReducer, useState} from 'react'
3
+ import {type SanityClient} from 'sanity'
4
+
5
+ type State = {
6
+ /**
7
+ * Growing list over live events with Sync Tags,
8
+ * that can be used to refetch with Sanity Client, using the id as the lastLiveEventId parameter
9
+ */
10
+ messages: LiveEventMessage[]
11
+ /**
12
+ * If the connection experiences a reconnect, or a restart event is received, the counter is incremented.
13
+ * This counter is suitable as a `key` on React Components as a way to reset its internal state and refetch.
14
+ */
15
+ resets: number
16
+ }
17
+
18
+ export function reducer(state: State, event: LiveEvent): State {
19
+ switch (event.type) {
20
+ case 'message':
21
+ return {
22
+ ...state,
23
+ messages: [...state.messages, event],
24
+ }
25
+ case 'reconnect':
26
+ case 'restart':
27
+ return {
28
+ ...state,
29
+ messages: [],
30
+ resets: state.resets + 1,
31
+ }
32
+ case 'welcome':
33
+ // no-op
34
+ return state
35
+ default:
36
+ throw Error(
37
+ `Unknown event: ${
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ (event as any).type
40
+ }`,
41
+ {cause: event},
42
+ )
43
+ }
44
+ }
45
+
46
+ export const initialState: State = {
47
+ messages: [],
48
+ resets: 0,
49
+ }
50
+
51
+ export function useLiveEvents(client: SanityClient): State {
52
+ const [state, dispatch] = useReducer(reducer, initialState)
53
+ const [error, setError] = useState<unknown>(null)
54
+ if (error !== null) {
55
+ // Push error to nearest error boundary
56
+ throw error
57
+ }
58
+
59
+ useEffect(() => {
60
+ const subscription = client.live
61
+ .events({includeDrafts: true, tag: 'presentation-loader'})
62
+ .subscribe({
63
+ next: dispatch,
64
+ error: (err) =>
65
+ setError(
66
+ err instanceof Error
67
+ ? err
68
+ : new Error('Unexpected error in useLiveEvents', {cause: err}),
69
+ ),
70
+ })
71
+ return () => subscription.unsubscribe()
72
+ }, [client.live])
73
+
74
+ return useDeferredValue(state)
75
+ }
@@ -0,0 +1,127 @@
1
+ import {type ClientPerspective} from '@sanity/client'
2
+ import isEqual from 'fast-deep-equal'
3
+ import {useDeferredValue, useEffect, useReducer} from 'react'
4
+ import {type QueryParams} from 'sanity'
5
+
6
+ import {LOADER_QUERY_GC_INTERVAL} from '../constants'
7
+ import {getQueryCacheKey, type QueryCacheKey} from './utils'
8
+
9
+ type LiveQueriesState = Map<
10
+ QueryCacheKey,
11
+ {
12
+ query: string
13
+ params: QueryParams
14
+ perspective: ClientPerspective
15
+ }
16
+ >
17
+
18
+ type State = {
19
+ queries: LiveQueriesState
20
+ heartbeats: Map<
21
+ QueryCacheKey,
22
+ {
23
+ receivedAt: number
24
+ /**
25
+ * If false it means the query can't safely be garbage collected,
26
+ * as older versions of \@sanity/core-loader doesn't fire listen events
27
+ * on an interval.
28
+ */
29
+ heartbeat: number | false
30
+ }
31
+ >
32
+ }
33
+
34
+ type QueryListenAction = {
35
+ type: 'query-listen'
36
+ payload: {
37
+ perspective: ClientPerspective
38
+ query: string
39
+ params: QueryParams
40
+ heartbeat: number | false
41
+ }
42
+ }
43
+ type GarbageCollectAction = {type: 'gc'}
44
+ type Action = QueryListenAction | GarbageCollectAction
45
+
46
+ function gc(state: State): State {
47
+ if (state.queries.size < 1) {
48
+ return state
49
+ }
50
+
51
+ const now = Date.now()
52
+ const hasAnyExpired = Array.from(state.heartbeats.values()).some(
53
+ (entry) => entry.heartbeat !== false && now > entry.receivedAt + entry.heartbeat,
54
+ )
55
+ if (!hasAnyExpired) {
56
+ return state
57
+ }
58
+ const nextHeartbeats = new Map()
59
+ const nextQueries = new Map()
60
+ for (const [key, entry] of state.heartbeats.entries()) {
61
+ if (entry.heartbeat !== false && now > entry.receivedAt + entry.heartbeat) {
62
+ continue
63
+ }
64
+ nextHeartbeats.set(key, entry)
65
+ nextQueries.set(key, state.queries.get(key))
66
+ }
67
+
68
+ return {...state, queries: nextQueries, heartbeats: nextHeartbeats}
69
+ }
70
+ function queryListen(state: State, {payload}: QueryListenAction): State {
71
+ const key = getQueryCacheKey(payload.perspective, payload.query, payload.params)
72
+ const data = {query: payload.query, params: payload.params, perspective: payload.perspective}
73
+
74
+ const nextHeartbeats = new Map(state.heartbeats)
75
+ nextHeartbeats.set(key, {
76
+ receivedAt: Date.now(),
77
+ heartbeat: payload.heartbeat,
78
+ })
79
+
80
+ let nextQueries = state.queries
81
+ /**
82
+ * The data comes from a postMessage event, which uses the structured clone algorithm to serialize state (https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#message).
83
+ * This impacts `params`, which is an object, as it will be a new object every time even if the sender is sending the same object instance on their end.
84
+ * It also impacts `perspective`, as it's no longer just a string, but can also be an array of strings.
85
+ * Both cases are handled by fast-deep-equal, which is used to compare the data before deciding wether the state should be updated.
86
+ */
87
+ if (!state.queries.has(key) || !isEqual(state.queries.get(key), data)) {
88
+ nextQueries = new Map(state.queries)
89
+ nextQueries.set(key, data)
90
+ }
91
+
92
+ return {heartbeats: nextHeartbeats, queries: nextQueries}
93
+ }
94
+
95
+ export function reducer(state: State, action: Action): State {
96
+ switch (action.type) {
97
+ case 'query-listen':
98
+ return queryListen(state, action)
99
+ case 'gc':
100
+ return gc(state)
101
+ default:
102
+ throw Error(
103
+ `Unknown action: ${
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ (action as any).type
106
+ }`,
107
+ {cause: action},
108
+ )
109
+ }
110
+ }
111
+
112
+ export const initialState: State = {
113
+ queries: new Map(),
114
+ heartbeats: new Map(),
115
+ }
116
+
117
+ export function useLiveQueries(): [LiveQueriesState, React.ActionDispatch<[action: Action]>] {
118
+ const [state, dispatch] = useReducer(reducer, initialState)
119
+
120
+ useEffect(() => {
121
+ const interval = setInterval(() => dispatch({type: 'gc'}), LOADER_QUERY_GC_INTERVAL)
122
+ return () => clearInterval(interval)
123
+ }, [])
124
+
125
+ const queries = useDeferredValue(state.queries)
126
+ return [queries, dispatch]
127
+ }