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.
- package/lib/_chunks-cjs/LiveQueries.js +236 -163
- package/lib/_chunks-cjs/LiveQueries.js.map +1 -1
- package/lib/_chunks-cjs/PresentationToolGrantsCheck.js +3 -7
- package/lib/_chunks-cjs/PresentationToolGrantsCheck.js.map +1 -1
- package/lib/_chunks-cjs/buildAction.js +3 -0
- package/lib/_chunks-cjs/buildAction.js.map +1 -1
- package/lib/_chunks-cjs/presentation.js +3 -4
- package/lib/_chunks-cjs/presentation.js.map +1 -1
- package/lib/_chunks-cjs/version.js +1 -1
- package/lib/_chunks-es/LiveQueries.mjs +235 -162
- package/lib/_chunks-es/LiveQueries.mjs.map +1 -1
- package/lib/_chunks-es/PresentationToolGrantsCheck.mjs +2 -4
- package/lib/_chunks-es/PresentationToolGrantsCheck.mjs.map +1 -1
- package/lib/_chunks-es/presentation.mjs +3 -3
- package/lib/_chunks-es/presentation.mjs.map +1 -1
- package/lib/_chunks-es/version.mjs +1 -1
- package/lib/_internal/cli/threads/validateDocuments.js +1 -1
- package/lib/_internal/cli/threads/validateDocuments.js.map +1 -1
- package/lib/_legacy/LiveQueries.esm.js +235 -162
- package/lib/_legacy/LiveQueries.esm.js.map +1 -1
- package/lib/_legacy/PresentationToolGrantsCheck.esm.js +2 -4
- package/lib/_legacy/PresentationToolGrantsCheck.esm.js.map +1 -1
- package/lib/_legacy/presentation.esm.js +3 -3
- package/lib/_legacy/presentation.esm.js.map +1 -1
- package/lib/_legacy/version.esm.js +1 -1
- package/package.json +13 -14
- package/src/_internal/cli/server/buildVendorDependencies.ts +1 -0
- package/src/_internal/cli/threads/validateDocuments.ts +2 -2
- package/src/presentation/PresentationTool.tsx +10 -21
- package/src/presentation/constants.ts +4 -16
- package/src/presentation/loader/LiveQueries.tsx +79 -234
- package/src/presentation/loader/useLiveEvents.ts +75 -0
- package/src/presentation/loader/useLiveQueries.ts +127 -0
- package/src/presentation/loader/utils.ts +2 -125
- package/src/presentation/types.ts +1 -18
- package/lib/_chunks-cjs/LoaderQueries.js +0 -281
- package/lib/_chunks-cjs/LoaderQueries.js.map +0 -1
- package/lib/_chunks-cjs/utils.js +0 -70
- package/lib/_chunks-cjs/utils.js.map +0 -1
- package/lib/_chunks-es/LoaderQueries.mjs +0 -288
- package/lib/_chunks-es/LoaderQueries.mjs.map +0 -1
- package/lib/_chunks-es/utils.mjs +0 -74
- package/lib/_chunks-es/utils.mjs.map +0 -1
- package/lib/_legacy/LoaderQueries.esm.js +0 -288
- package/lib/_legacy/LoaderQueries.esm.js.map +0 -1
- package/lib/_legacy/utils.esm.js +0 -74
- package/lib/_legacy/utils.esm.js.map +0 -1
- 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
|
-
|
24
|
-
import {
|
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
|
-
|
36
|
-
import {
|
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 {
|
28
|
+
import {useLiveEvents} from './useLiveEvents'
|
29
|
+
import {useLiveQueries} from './useLiveQueries'
|
30
|
+
import {mapChangedValue} from './utils'
|
45
31
|
|
46
|
-
export interface
|
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
|
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,
|
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
|
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(
|
65
|
+
setComlink(nextComlink)
|
117
66
|
|
118
|
-
|
67
|
+
nextComlink.onStatus(onLoadersConnection)
|
119
68
|
|
120
|
-
|
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
|
-
|
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
|
-
|
142
|
-
|
143
|
-
|
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
|
-
}
|
150
|
-
})
|
97
|
+
},
|
98
|
+
})
|
151
99
|
}
|
152
100
|
})
|
153
101
|
|
154
|
-
return
|
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
|
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
|
202
|
-
dataset
|
118
|
+
projectId,
|
119
|
+
dataset,
|
203
120
|
perspective: activePerspective,
|
204
121
|
})
|
205
122
|
}
|
206
|
-
}, [comlink,
|
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(
|
128
|
+
const liveDocument = useDeferredValue(props.liveDocument)
|
129
|
+
|
130
|
+
const liveEvents = useLiveEvents(client)
|
245
131
|
|
246
132
|
return (
|
247
133
|
<>
|
248
|
-
{
|
134
|
+
{[...liveQueries.entries()].map(([key, {query, params, perspective}]) => (
|
249
135
|
<QuerySubscription
|
250
|
-
key={`${
|
251
|
-
projectId={
|
252
|
-
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
|
-
|
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' | '
|
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
|
-
|
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
|
-
|
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
|
-
|
236
|
+
liveEventsMessages: LiveEventMessage[]
|
368
237
|
}
|
369
238
|
function useQuerySubscription(props: UseQuerySubscriptionProps) {
|
370
|
-
const {liveDocument, client, query, params, perspective,
|
371
|
-
const [
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
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
|
-
|
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
|
-
|
398
|
-
|
399
|
-
|
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
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
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
|
-
|
282
|
+
|
432
283
|
return () => {
|
433
|
-
|
434
|
-
controller.abort()
|
435
|
-
}
|
284
|
+
controller.abort()
|
436
285
|
}
|
437
|
-
}, [client, lastLiveEventId, params, perspective, query
|
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
|
+
}
|