From d1dc5b5d13f7cc106f51617678b87b5af5380d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rge=20N=C3=A6ss?= Date: Tue, 21 Jan 2025 12:04:08 +0100 Subject: [PATCH] feat(preview): add experimental support for live document id sets (#7398) --- packages/sanity/package.json | 1 + .../src/core/preview/documentPreviewStore.ts | 35 +++++- .../src/core/preview/liveDocumentIdSet.ts | 112 ++++++++++++++++++ .../src/core/preview/useLiveDocumentIdSet.ts | 47 ++++++++ .../src/core/preview/useLiveDocumentSet.ts | 34 ++++++ pnpm-lock.yaml | 13 ++ 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/sanity/src/core/preview/liveDocumentIdSet.ts create mode 100644 packages/sanity/src/core/preview/useLiveDocumentIdSet.ts create mode 100644 packages/sanity/src/core/preview/useLiveDocumentSet.ts diff --git a/packages/sanity/package.json b/packages/sanity/package.json index 664e281b714..a22a92f7e1f 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -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", diff --git a/packages/sanity/src/core/preview/documentPreviewStore.ts b/packages/sanity/src/core/preview/documentPreviewStore.ts index 487e1a27768..245dbf44eca 100644 --- a/packages/sanity/src/core/preview/documentPreviewStore.ts +++ b/packages/sanity/src/core/preview/documentPreviewStore.ts @@ -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' @@ -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, @@ -58,6 +64,30 @@ export interface DocumentPreviewStore { paths: PreviewPath[], ) => Observable> + /** + * 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 + /** * Observe a complete document with the given ID * @hidden @@ -107,6 +137,8 @@ export function createDocumentPreviewStore({ ) } + const observeDocumentIdSet = createDocumentIdSetObserver(versionedClient) + const observeForPreview = createPreviewObserver({observeDocumentTypeFromId, observePaths}) const observeDocumentPairAvailability = createPreviewAvailabilityObserver( versionedClient, @@ -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))), diff --git a/packages/sanity/src/core/preview/liveDocumentIdSet.ts b/packages/sanity/src/core/preview/liveDocumentIdSet.ts new file mode 100644 index 00000000000..49d8401cde1 --- /dev/null +++ b/packages/sanity/src/core/preview/liveDocumentIdSet.ts @@ -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(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) +} diff --git a/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts new file mode 100644 index 00000000000..2fa3eff626d --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentIdSet.ts @@ -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) +} diff --git a/packages/sanity/src/core/preview/useLiveDocumentSet.ts b/packages/sanity/src/core/preview/useLiveDocumentSet.ts new file mode 100644 index 00000000000..16c5c27be24 --- /dev/null +++ b/packages/sanity/src/core/preview/useLiveDocumentSet.ts @@ -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) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41da359084d..4a8d549e7aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1649,6 +1649,9 @@ importers: rxjs-exhaustmap-with-trailing: specifier: ^2.1.1 version: 2.1.1(rxjs@7.8.1) + rxjs-mergemap-array: + specifier: ^0.1.0 + version: 0.1.0(rxjs@7.8.1) sanity-diff-patch: specifier: ^4.0.0 version: 4.0.0 @@ -10423,6 +10426,12 @@ packages: peerDependencies: rxjs: 7.x + rxjs-mergemap-array@0.1.0: + resolution: {integrity: sha512-19fXxPXN4X8LPWu7fg/nyX+nr0G97qSNXhEvF32cdgWuoyUVQ4MrFr+UL4HGip6iO5kbZOL4puAjPeQ/D5qSlA==} + engines: {node: '>=18.0.0'} + peerDependencies: + rxjs: 7.x + rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -22073,6 +22082,10 @@ snapshots: dependencies: rxjs: 7.8.1 + rxjs-mergemap-array@0.1.0(rxjs@7.8.1): + dependencies: + rxjs: 7.8.1 + rxjs@6.6.7: dependencies: tslib: 1.14.1