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,564 +0,0 @@
1
- import {
2
- type ClientConfig,
3
- type ClientPerspective,
4
- type ContentSourceMap,
5
- type QueryParams,
6
- type SyncTag,
7
- } from '@sanity/client'
8
- import {applySourceDocuments, getPublishedId} from '@sanity/client/csm'
9
- import {
10
- type ChannelInstance,
11
- type Controller,
12
- createConnectionMachine,
13
- type StatusEvent,
14
- } from '@sanity/comlink'
15
- import {
16
- createCompatibilityActors,
17
- type LoaderControllerMsg,
18
- type LoaderNodeMsg,
19
- } from '@sanity/presentation-comlink'
20
- import {applyPatch} from 'mendoza'
21
- import LRUCache from 'mnemonist/lru-cache-with-delete'
22
- import {memo, useEffect, useMemo, useState} from 'react'
23
- import {
24
- type SanityClient,
25
- type SanityDocument,
26
- useClient,
27
- // useCurrentUser,
28
- useDataset,
29
- useProjectId,
30
- } from 'sanity'
31
-
32
- import {
33
- LIVE_QUERY_CACHE_BATCH_SIZE,
34
- LIVE_QUERY_CACHE_SIZE,
35
- MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL,
36
- } from '../constants'
37
- import {
38
- type LiveQueriesState,
39
- type LiveQueriesStateValue,
40
- type LoaderConnection,
41
- type PresentationPerspective,
42
- } from '../types'
43
- import {type DocumentOnPage} from '../useDocumentsOnPage'
44
- import {mapChangedValue, useQueryParams, useRevalidate} from './utils'
45
-
46
- export interface LoaderQueriesProps {
47
- liveDocument: Partial<SanityDocument> | null | undefined
48
- controller: Controller | undefined
49
- perspective: ClientPerspective
50
- documentsOnPage: {_id: string; _type: string}[]
51
- onLoadersConnection: (event: StatusEvent) => void
52
- onDocumentsOnPage: (
53
- key: string,
54
- perspective: PresentationPerspective,
55
- state: DocumentOnPage[],
56
- ) => void
57
- }
58
-
59
- export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Element {
60
- const {
61
- liveDocument,
62
- controller,
63
- perspective: activePerspective,
64
- documentsOnPage,
65
- onLoadersConnection,
66
- onDocumentsOnPage,
67
- } = props
68
-
69
- const [comlink, setComlink] = useState<ChannelInstance<LoaderControllerMsg, LoaderNodeMsg>>()
70
- const [liveQueries, setLiveQueries] = useState<LiveQueriesState>({})
71
-
72
- const projectId = useProjectId()
73
- const dataset = useDataset()
74
-
75
- useEffect(() => {
76
- const interval = setInterval(
77
- () =>
78
- // eslint-disable-next-line @typescript-eslint/no-shadow
79
- setLiveQueries((liveQueries) => {
80
- if (Object.keys(liveQueries).length < 1) {
81
- return liveQueries
82
- }
83
-
84
- const now = Date.now()
85
- const hasAnyExpired = Object.values(liveQueries).some(
86
- // eslint-disable-next-line max-nested-callbacks
87
- (liveQuery) =>
88
- liveQuery.heartbeat !== false && now > liveQuery.receivedAt + liveQuery.heartbeat,
89
- )
90
- if (!hasAnyExpired) {
91
- return liveQueries
92
- }
93
- const next = {} as LiveQueriesState
94
- for (const [key, value] of Object.entries(liveQueries)) {
95
- if (value.heartbeat !== false && now > value.receivedAt + value.heartbeat) {
96
- continue
97
- }
98
- next[key] = value
99
- }
100
- return next
101
- }),
102
- MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL,
103
- )
104
- return () => clearInterval(interval)
105
- }, [])
106
-
107
- useEffect(() => {
108
- if (controller) {
109
- // eslint-disable-next-line @typescript-eslint/no-shadow
110
- const comlink = controller.createChannel<LoaderControllerMsg, LoaderNodeMsg>(
111
- {
112
- name: 'presentation',
113
- connectTo: 'loaders',
114
- heartbeat: true,
115
- },
116
- createConnectionMachine<LoaderControllerMsg, LoaderNodeMsg>().provide({
117
- actors: createCompatibilityActors<LoaderControllerMsg>(),
118
- }),
119
- )
120
- setComlink(comlink)
121
-
122
- comlink.onStatus(onLoadersConnection)
123
-
124
- comlink.on('loader/documents', (data) => {
125
- if (data.projectId === projectId && data.dataset === dataset) {
126
- onDocumentsOnPage(
127
- 'loaders',
128
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
- data.perspective as unknown as any,
130
- data.documents,
131
- )
132
- }
133
- })
134
-
135
- comlink.on('loader/query-listen', (data) => {
136
- if (data.projectId === projectId && data.dataset === dataset) {
137
- if (
138
- typeof data.heartbeat === 'number' &&
139
- data.heartbeat < MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL
140
- ) {
141
- throw new Error(
142
- `Loader query listen heartbeat interval must be at least ${MIN_LOADER_QUERY_LISTEN_HEARTBEAT_INTERVAL}ms`,
143
- )
144
- }
145
- setLiveQueries((prev) => ({
146
- ...prev,
147
- [getQueryCacheKey(data.query, data.params)]: {
148
- perspective: data.perspective,
149
- query: data.query,
150
- params: data.params,
151
- receivedAt: Date.now(),
152
- heartbeat: data.heartbeat ?? false,
153
- } satisfies LiveQueriesStateValue,
154
- }))
155
- }
156
- })
157
-
158
- return comlink.start()
159
- }
160
- return undefined
161
- }, [controller, dataset, onDocumentsOnPage, onLoadersConnection, projectId])
162
-
163
- const [cache] = useState(() => new LRUCache<string, SanityDocument>(LIVE_QUERY_CACHE_SIZE))
164
- const studioClient = useClient({apiVersion: '2023-10-16'})
165
- const clientConfig = useMemo(() => studioClient.config(), [studioClient])
166
- const client = useMemo(
167
- () =>
168
- studioClient.withConfig({
169
- resultSourceMap: 'withKeyArraySelector',
170
- }),
171
- [studioClient],
172
- )
173
- useEffect(() => {
174
- if (comlink) {
175
- // eslint-disable-next-line @typescript-eslint/no-shadow
176
- const {projectId, dataset} = clientConfig
177
- comlink.post('loader/perspective', {
178
- projectId: projectId!,
179
- dataset: dataset!,
180
- perspective: activePerspective,
181
- })
182
- }
183
- }, [comlink, clientConfig, activePerspective])
184
-
185
- const turboIds = useMemo(() => {
186
- const documentsActuallyInUse = documentsOnPage.map(({_id}) => _id)
187
- const set = new Set(documentsActuallyInUse)
188
- const ids = [...set]
189
- const max = cache.capacity
190
- if (ids.length >= max) {
191
- ids.length = max
192
- }
193
- return ids
194
- }, [cache.capacity, documentsOnPage])
195
-
196
- const [documentsCacheLastUpdated, setDocumentsCacheLastUpdated] = useState(0)
197
-
198
- return (
199
- <>
200
- <Turbo
201
- cache={cache}
202
- client={client}
203
- turboIds={turboIds}
204
- setDocumentsCacheLastUpdated={setDocumentsCacheLastUpdated}
205
- />
206
- {Object.entries(liveQueries).map(([key, {query, params, perspective}]) => (
207
- <QuerySubscription
208
- key={`${key}${perspective}`}
209
- cache={cache}
210
- projectId={clientConfig.projectId!}
211
- dataset={clientConfig.dataset!}
212
- perspective={perspective}
213
- query={query}
214
- params={params}
215
- comlink={comlink}
216
- client={client}
217
- refreshInterval={activePerspective ? 2000 : 0}
218
- liveDocument={liveDocument}
219
- documentsCacheLastUpdated={documentsCacheLastUpdated}
220
- />
221
- ))}
222
- </>
223
- )
224
- }
225
-
226
- interface SharedProps {
227
- /**
228
- * The Sanity client to use for fetching data and listening to mutations.
229
- */
230
- client: SanityClient
231
- /**
232
- * How frequently queries should be refetched in the background to refresh the parts of queries that can't be source mapped.
233
- * Setting it to `0` will disable background refresh.
234
- * @defaultValue 10000
235
- */
236
- refreshInterval?: number
237
- /**
238
- * The documents cache to use for turbo-charging queries.
239
- */
240
- cache: LRUCache<string, SanityDocument>
241
- }
242
-
243
- interface TurboProps extends Pick<SharedProps, 'client' | 'cache'> {
244
- turboIds: string[]
245
- setDocumentsCacheLastUpdated: (timestamp: number) => void
246
- }
247
- /**
248
- * A turbo-charged mutation observer that uses Content Source Maps to apply mendoza patches on your queries
249
- */
250
- const Turbo = memo(function Turbo(props: TurboProps) {
251
- const {cache, client, turboIds, setDocumentsCacheLastUpdated} = props
252
- // Figure out which documents are missing from the cache
253
- const [batch, setBatch] = useState<string[][]>([])
254
- useEffect(() => {
255
- const batchSet = new Set(batch.flat())
256
- const nextBatch = new Set<string>()
257
- for (const turboId of turboIds) {
258
- if (!batchSet.has(turboId) && !cache.has(turboId)) {
259
- nextBatch.add(turboId)
260
- }
261
- }
262
- const nextBatchSlice = [...nextBatch].slice(0, LIVE_QUERY_CACHE_BATCH_SIZE)
263
- if (nextBatchSlice.length === 0) return undefined
264
- const raf = requestAnimationFrame(() =>
265
- // eslint-disable-next-line max-nested-callbacks
266
- setBatch((prevBatch) => [...prevBatch.slice(-LIVE_QUERY_CACHE_BATCH_SIZE), nextBatchSlice]),
267
- )
268
- return () => cancelAnimationFrame(raf)
269
- }, [batch, cache, turboIds])
270
-
271
- // Use the same listen instance and patch documents as they come in
272
- useEffect(() => {
273
- const subscription = client
274
- .listen(
275
- '*',
276
- {},
277
- {
278
- events: ['mutation'],
279
- effectFormat: 'mendoza',
280
- includePreviousRevision: false,
281
- includeResult: false,
282
- tag: 'presentation-loader',
283
- },
284
- )
285
- .subscribe((update) => {
286
- if (update.type === 'mutation' && update.transition === 'disappear') {
287
- if (cache.delete(update.documentId)) {
288
- setDocumentsCacheLastUpdated(Date.now())
289
- }
290
- }
291
-
292
- if (update.type !== 'mutation' || !update.effects?.apply?.length) return
293
- // Schedule a reach state update with the ID of the document that were mutated
294
- // This react handler will apply the document to related source map snapshots
295
- const cachedDocument = cache.peek(update.documentId)
296
- if (cachedDocument as SanityDocument) {
297
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
- const patchDoc = {...cachedDocument} as any
299
- delete patchDoc._rev
300
- const patchedDocument = applyPatch(patchDoc, update.effects.apply)
301
- cache.set(update.documentId, patchedDocument)
302
- setDocumentsCacheLastUpdated(Date.now())
303
- }
304
- })
305
- return () => subscription.unsubscribe()
306
- }, [cache, client, setDocumentsCacheLastUpdated])
307
-
308
- return (
309
- <>
310
- {batch.map((ids) => (
311
- <GetDocuments
312
- key={JSON.stringify(ids)}
313
- cache={cache}
314
- client={client}
315
- ids={ids}
316
- setDocumentsCacheLastUpdated={setDocumentsCacheLastUpdated}
317
- />
318
- ))}
319
- </>
320
- )
321
- })
322
-
323
- interface GetDocumentsProps extends Pick<SharedProps, 'client' | 'cache'> {
324
- ids: string[]
325
- setDocumentsCacheLastUpdated: (timestamp: number) => void
326
- }
327
- const GetDocuments = memo(function GetDocuments(props: GetDocumentsProps) {
328
- const {client, cache, ids, setDocumentsCacheLastUpdated} = props
329
-
330
- useEffect(() => {
331
- const missingIds = ids.filter((id) => !cache.has(id))
332
- if (missingIds.length === 0) return
333
- client.getDocuments(missingIds).then((documents) => {
334
- for (const doc of documents) {
335
- if (doc && doc?._id) {
336
- cache.set(doc._id, doc)
337
- setDocumentsCacheLastUpdated(Date.now())
338
- }
339
- }
340
- // eslint-disable-next-line no-console
341
- }, console.error)
342
- }, [cache, client, ids, setDocumentsCacheLastUpdated])
343
-
344
- return null
345
- })
346
- GetDocuments.displayName = 'GetDocuments'
347
-
348
- interface QuerySubscriptionProps
349
- extends Pick<
350
- UseQuerySubscriptionProps,
351
- 'client' | 'cache' | 'refreshInterval' | 'liveDocument' | 'documentsCacheLastUpdated'
352
- > {
353
- projectId: string
354
- dataset: string
355
- perspective: ClientPerspective
356
- query: string
357
- params: QueryParams
358
- comlink: LoaderConnection | undefined
359
- }
360
- function QuerySubscription(props: QuerySubscriptionProps) {
361
- const {
362
- cache,
363
- projectId,
364
- dataset,
365
- perspective,
366
- query,
367
- client,
368
- refreshInterval,
369
- liveDocument,
370
- comlink,
371
- documentsCacheLastUpdated,
372
- } = props
373
-
374
- const params = useQueryParams(props.params)
375
- const data = useQuerySubscription({
376
- cache,
377
- client,
378
- liveDocument,
379
- params,
380
- perspective,
381
- query,
382
- refreshInterval,
383
- documentsCacheLastUpdated,
384
- })
385
- const result = data?.result
386
- const resultSourceMap = data?.resultSourceMap
387
- const tags = data?.tags
388
-
389
- useEffect(() => {
390
- if (resultSourceMap) {
391
- comlink?.post('loader/query-change', {
392
- projectId,
393
- dataset,
394
- perspective,
395
- query,
396
- params,
397
- result,
398
- resultSourceMap,
399
- tags,
400
- })
401
- }
402
- }, [comlink, dataset, params, perspective, projectId, query, result, resultSourceMap, tags])
403
-
404
- return null
405
- }
406
-
407
- interface UseQuerySubscriptionProps
408
- extends Required<Pick<SharedProps, 'client' | 'refreshInterval' | 'cache'>> {
409
- liveDocument: Partial<SanityDocument> | null | undefined
410
- query: string
411
- params: QueryParams
412
- perspective: ClientPerspective
413
- documentsCacheLastUpdated: number
414
- }
415
- function useQuerySubscription(props: UseQuerySubscriptionProps) {
416
- const {
417
- cache,
418
- liveDocument,
419
- client,
420
- refreshInterval,
421
- query,
422
- params,
423
- perspective,
424
- documentsCacheLastUpdated,
425
- } = props
426
- const [snapshot, setSnapshot] = useState<{
427
- result: unknown
428
- resultSourceMap?: ContentSourceMap
429
- tags?: SyncTag[]
430
- } | null>(null)
431
- const {projectId, dataset} = useMemo(() => {
432
- // eslint-disable-next-line @typescript-eslint/no-shadow
433
- const {projectId, dataset} = client.config()
434
- return {projectId, dataset} as Required<Pick<ClientConfig, 'projectId' | 'dataset'>>
435
- }, [client])
436
-
437
- // Make sure any async errors bubble up to the nearest error boundary
438
- const [error, setError] = useState<unknown>(null)
439
- if (error) throw error
440
-
441
- const [revalidate, startRefresh] = useRevalidate({refreshInterval})
442
- const shouldRefetch = revalidate === 'refresh' || revalidate === 'inflight'
443
- useEffect(() => {
444
- if (!shouldRefetch) {
445
- return undefined
446
- }
447
-
448
- let fulfilled = false
449
- let fetching = false
450
- const controller = new AbortController()
451
- // eslint-disable-next-line no-inner-declarations
452
- async function effect() {
453
- const {signal} = controller
454
- fetching = true
455
- const {result, resultSourceMap, syncTags} = await client.fetch(query, params, {
456
- tag: 'presentation-loader',
457
- signal,
458
- perspective,
459
- filterResponse: false,
460
- })
461
- fetching = false
462
-
463
- if (!signal.aborted) {
464
- setSnapshot({result, resultSourceMap, tags: syncTags})
465
-
466
- fulfilled = true
467
- }
468
- }
469
- const onFinally = startRefresh()
470
- effect()
471
- // eslint-disable-next-line @typescript-eslint/no-shadow
472
- .catch((error) => {
473
- fetching = false
474
- if (error.name !== 'AbortError') {
475
- setError(error)
476
- }
477
- })
478
- .finally(onFinally)
479
- return () => {
480
- if (!fulfilled && !fetching) {
481
- controller.abort()
482
- }
483
- }
484
- }, [
485
- client,
486
- dataset,
487
- liveDocument,
488
- params,
489
- perspective,
490
- projectId,
491
- query,
492
- shouldRefetch,
493
- startRefresh,
494
- ])
495
-
496
- return useMemo(() => {
497
- if (documentsCacheLastUpdated && snapshot?.resultSourceMap) {
498
- return {
499
- result: turboChargeResultIfSourceMap(
500
- cache,
501
- liveDocument,
502
- snapshot.result,
503
- perspective,
504
- snapshot.resultSourceMap,
505
- ),
506
- resultSourceMap: snapshot.resultSourceMap,
507
- }
508
- }
509
- return snapshot
510
- }, [cache, documentsCacheLastUpdated, liveDocument, perspective, snapshot])
511
- }
512
-
513
- let warnedAboutCrossDatasetReference = false
514
- export function turboChargeResultIfSourceMap<T = unknown>(
515
- cache: SharedProps['cache'],
516
- liveDocument: Partial<SanityDocument> | null | undefined,
517
- result: T,
518
- perspective: ClientPerspective,
519
- resultSourceMap?: ContentSourceMap,
520
- ): T {
521
- if (perspective === 'raw') {
522
- throw new Error('turboChargeResultIfSourceMap does not support raw perspective')
523
- }
524
- return applySourceDocuments(
525
- result,
526
- resultSourceMap,
527
- (sourceDocument) => {
528
- if (sourceDocument._projectId) {
529
- // @TODO Handle cross dataset references
530
- if (!warnedAboutCrossDatasetReference) {
531
- // eslint-disable-next-line no-console
532
- console.warn(
533
- 'Cross dataset references are not supported yet, ignoring source document',
534
- sourceDocument,
535
- )
536
- warnedAboutCrossDatasetReference = true
537
- }
538
- return undefined
539
- }
540
- // If there's a displayed document, always prefer it
541
- if (
542
- liveDocument?._id &&
543
- getPublishedId(liveDocument._id) === getPublishedId(sourceDocument._id)
544
- ) {
545
- if (typeof liveDocument._id === 'string' && typeof sourceDocument._type === 'string') {
546
- return liveDocument as unknown as Required<Pick<SanityDocument, '_id' | '_type'>>
547
- }
548
- return {
549
- ...liveDocument,
550
- _id: liveDocument._id || sourceDocument._id,
551
- _type: liveDocument._type || sourceDocument._type,
552
- }
553
- }
554
- // Fallback to general documents cache
555
- return cache.get(sourceDocument._id)
556
- },
557
- mapChangedValue,
558
- perspective,
559
- )
560
- }
561
-
562
- function getQueryCacheKey(query: string, params: QueryParams | string): `${string}-${string}` {
563
- return `${query}-${typeof params === 'string' ? params : JSON.stringify(params)}`
564
- }