Skip to content

Commit

Permalink
feat(preview): add experimental support for live document id sets (#7398
Browse files Browse the repository at this point in the history
)
  • Loading branch information
bjoerge authored Jan 21, 2025
1 parent 044f24c commit d1dc5b5
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
"rimraf": "^5.0.10",
"rxjs": "^7.8.0",
"rxjs-exhaustmap-with-trailing": "^2.1.1",
"rxjs-mergemap-array": "^0.1.0",
"sanity-diff-patch": "^4.0.0",
"scroll-into-view-if-needed": "^3.0.3",
"semver": "^7.3.5",
Expand Down
35 changes: 34 additions & 1 deletion packages/sanity/src/core/preview/documentPreviewStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client'
import {
type MutationEvent,
type QueryParams,
type SanityClient,
type WelcomeEvent,
} from '@sanity/client'
import {type PrepareViewOptions, type SanityDocument} from '@sanity/types'
import {combineLatest, type Observable} from 'rxjs'
import {distinctUntilChanged, filter, map} from 'rxjs/operators'
Expand All @@ -10,6 +15,7 @@ import {createObserveDocument} from './createObserveDocument'
import {createPathObserver} from './createPathObserver'
import {createPreviewObserver} from './createPreviewObserver'
import {createObservePathsDocumentPair} from './documentPair'
import {createDocumentIdSetObserver, type DocumentIdSetObserverState} from './liveDocumentIdSet'
import {createObserveFields} from './observeFields'
import {
type ApiConfig,
Expand Down Expand Up @@ -58,6 +64,30 @@ export interface DocumentPreviewStore {
paths: PreviewPath[],
) => Observable<DraftsModelDocument<T>>

/**
* Observes a set of document IDs that matches the given groq-filter. The document ids are returned in ascending order and will update in real-time
* Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be pushed to subscribers.
* The query is performed once, initially, and thereafter the set of ids are patched based on the `appear` and `disappear`
* transitions on the received listener events.
* This provides a lightweight way of subscribing to a list of ids for simple cases where you just want to subscribe to a set of documents ids
* that matches a particular filter.
* @hidden
* @beta
* @param filter - A groq filter to use for the document set
* @param params - Parameters to use with the groq filter
* @param options - Options for the observer
*/
unstable_observeDocumentIdSet: (
filter: string,
params?: QueryParams,
options?: {
/**
* Where to insert new items into the set. Defaults to 'sorted' which is based on the lexicographic order of the id
*/
insert?: 'sorted' | 'prepend' | 'append'
},
) => Observable<DocumentIdSetObserverState>

/**
* Observe a complete document with the given ID
* @hidden
Expand Down Expand Up @@ -107,6 +137,8 @@ export function createDocumentPreviewStore({
)
}

const observeDocumentIdSet = createDocumentIdSetObserver(versionedClient)

const observeForPreview = createPreviewObserver({observeDocumentTypeFromId, observePaths})
const observeDocumentPairAvailability = createPreviewAvailabilityObserver(
versionedClient,
Expand All @@ -125,6 +157,7 @@ export function createDocumentPreviewStore({
observeForPreview,
observeDocumentTypeFromId,

unstable_observeDocumentIdSet: observeDocumentIdSet,
unstable_observeDocument: observeDocument,
unstable_observeDocuments: (ids: string[]) =>
combineLatest(ids.map((id) => observeDocument(id))),
Expand Down
112 changes: 112 additions & 0 deletions packages/sanity/src/core/preview/liveDocumentIdSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {type QueryParams, type SanityClient} from '@sanity/client'
import {sortedIndex} from 'lodash'
import {of} from 'rxjs'
import {distinctUntilChanged, filter, map, mergeMap, scan, tap} from 'rxjs/operators'

export type DocumentIdSetObserverState = {
status: 'reconnecting' | 'connected'
documentIds: string[]
}

interface LiveDocumentIdSetOptions {
insert?: 'sorted' | 'prepend' | 'append'
}

export function createDocumentIdSetObserver(client: SanityClient) {
return function observe(
queryFilter: string,
params?: QueryParams,
options: LiveDocumentIdSetOptions = {},
) {
const {insert: insertOption = 'sorted'} = options

const query = `*[${queryFilter}]._id`
function fetchFilter() {
return client.observable
.fetch(query, params, {
tag: 'preview.observe-document-set.fetch',
})
.pipe(
tap((result) => {
if (!Array.isArray(result)) {
throw new Error(
`Expected query to return array of documents, but got ${typeof result}`,
)
}
}),
)
}
return client.observable
.listen(query, params, {
visibility: 'transaction',
events: ['welcome', 'mutation', 'reconnect'],
includeResult: false,
includeMutations: false,
tag: 'preview.observe-document-set.listen',
})
.pipe(
mergeMap((event) => {
return event.type === 'welcome'
? fetchFilter().pipe(map((result) => ({type: 'fetch' as const, result})))
: of(event)
}),
scan(
(
state: DocumentIdSetObserverState | undefined,
event,
): DocumentIdSetObserverState | undefined => {
if (event.type === 'reconnect') {
return {
documentIds: state?.documentIds || [],
...state,
status: 'reconnecting' as const,
}
}
if (event.type === 'fetch') {
return {...state, status: 'connected' as const, documentIds: event.result}
}
if (event.type === 'mutation') {
if (event.transition === 'update') {
// ignore updates, as we're only interested in documents appearing and disappearing from the set
return state
}
if (event.transition === 'appear') {
return {
status: 'connected',
documentIds: insert(state?.documentIds || [], event.documentId, insertOption),
}
}
if (event.transition === 'disappear') {
return {
status: 'connected',
documentIds: state?.documentIds
? state.documentIds.filter((id) => id !== event.documentId)
: [],
}
}
}
return state
},
undefined,
),
distinctUntilChanged(),
filter(
(state: DocumentIdSetObserverState | undefined): state is DocumentIdSetObserverState =>
state !== undefined,
),
)
}
}

function insert<T>(array: T[], element: T, strategy: 'sorted' | 'prepend' | 'append') {
let index
if (strategy === 'prepend') {
index = 0
} else if (strategy === 'append') {
index = array.length
} else {
index = sortedIndex(array, element)
}

return array.toSpliced(index, 0, element)
}
47 changes: 47 additions & 0 deletions packages/sanity/src/core/preview/useLiveDocumentIdSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {type QueryParams} from '@sanity/client'
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {scan} from 'rxjs/operators'

import {useDocumentPreviewStore} from '../store/_legacy/datastores'
import {type DocumentIdSetObserverState} from './liveDocumentIdSet'

const INITIAL_STATE = {status: 'loading' as const, documentIds: []}

export type LiveDocumentSetState =
| {status: 'loading'; documentIds: string[]}
| DocumentIdSetObserverState

/**
* @internal
* @beta
* Returns document ids that matches the provided GROQ-filter, and loading state
* The document ids are returned in ascending order and will update in real-time
* Whenever a document appears or disappears from the set, a new array with the updated set of IDs will be returned.
* This provides a lightweight way of subscribing to a list of ids for simple cases where you just want the documents ids
* that matches a particular filter.
*/
export function useLiveDocumentIdSet(
filter: string,
params?: QueryParams,
options: {
// how to insert new document ids. Defaults to `sorted`
insert?: 'sorted' | 'prepend' | 'append'
} = {},
) {
const documentPreviewStore = useDocumentPreviewStore()
const observable = useMemo(
() =>
documentPreviewStore.unstable_observeDocumentIdSet(filter, params, options).pipe(
scan(
(currentState: LiveDocumentSetState, nextState) => ({
...currentState,
...nextState,
}),
INITIAL_STATE,
),
),
[documentPreviewStore, filter, params, options],
)
return useObservable(observable, INITIAL_STATE)
}
34 changes: 34 additions & 0 deletions packages/sanity/src/core/preview/useLiveDocumentSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {type QueryParams} from '@sanity/client'
import {type SanityDocument} from '@sanity/types'
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {map} from 'rxjs/operators'
import {mergeMapArray} from 'rxjs-mergemap-array'

import {useDocumentPreviewStore} from '../store'

const INITIAL_VALUE = {loading: true, documents: []}

/**
* @internal
* @beta
*
* Observes a set of documents matching the filter and returns an array of complete documents
* A new array will be pushed whenever a document in the set changes
* Document ids are returned in ascending order
* Any sorting beyond that must happen client side
*/
export function useLiveDocumentSet(
groqFilter: string,
params?: QueryParams,
): {loading: boolean; documents: SanityDocument[]} {
const documentPreviewStore = useDocumentPreviewStore()
const observable = useMemo(() => {
return documentPreviewStore.unstable_observeDocumentIdSet(groqFilter, params).pipe(
map((state) => (state.documentIds || []) as string[]),
mergeMapArray((id) => documentPreviewStore.unstable_observeDocument(id)),
map((docs) => ({loading: false, documents: docs as SanityDocument[]})),
)
}, [documentPreviewStore, groqFilter, params])
return useObservable(observable, INITIAL_VALUE)
}
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d1dc5b5

Please sign in to comment.