Skip to content

Commit

Permalink
feat(preview): add unstable_observeDocument + unstable_observeDocumen…
Browse files Browse the repository at this point in the history
…ts to preview store
  • Loading branch information
bjoerge committed Jul 17, 2024
1 parent ef3a142 commit 598a960
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 36 deletions.
26 changes: 13 additions & 13 deletions packages/sanity/src/core/preview/createGlobalListener.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client'
import {merge, timer} from 'rxjs'
import {defer, merge, timer} from 'rxjs'
import {filter, share, shareReplay} from 'rxjs/operators'

/**
* @internal
* Creates a listener that will emit 'welcome' for all new subscribers immediately, and thereafter emit at every mutation event
*/
export function createGlobalListener(client: SanityClient) {
const allEvents$ = client
.listen(
const allEvents$ = defer(() =>
client.listen(
'*[!(_id in path("_.**"))]',
{},
{
Expand All @@ -19,19 +19,19 @@ export function createGlobalListener(client: SanityClient) {
effectFormat: 'mendoza',
tag: 'preview.global',
},
)
.pipe(
filter(
(event): event is WelcomeEvent | MutationEvent =>
event.type === 'welcome' || event.type === 'mutation',
),
share({resetOnRefCountZero: () => timer(2000)}),
)
),
).pipe(
filter(
(event): event is WelcomeEvent | MutationEvent =>
event.type === 'welcome' || event.type === 'mutation',
),
share({resetOnRefCountZero: () => timer(2000), resetOnComplete: true}),
)

const welcome$ = allEvents$.pipe(
filter((event) => event.type === 'welcome'),
shareReplay(1),
shareReplay({refCount: true, bufferSize: 1}),
)
const mutations$ = allEvents$.pipe(filter((event) => event.type === 'mutation'))
const mutations$ = allEvents$.pipe(filter((event) => event.type === 'mutation')).pipe(share())
return merge(welcome$, mutations$)
}
71 changes: 58 additions & 13 deletions packages/sanity/src/core/preview/createObserveDocument.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client'
import {uniq} from 'lodash'
import {EMPTY, type Observable, of} from 'rxjs'
import {concatMap, map, scan} from 'rxjs/operators'
import {type SanityDocument} from '@sanity/types'
import {memoize, uniq} from 'lodash'
import {EMPTY, finalize, type Observable, of} from 'rxjs'
import {concatMap, map, scan, shareReplay} from 'rxjs/operators'

import {type ApiConfig} from './types'
import {applyMendozaPatch} from './utils/applyMendozaPatch'
import {debounceCollect} from './utils/debounceCollect'

export function createObserveDocument({
Expand All @@ -12,30 +15,72 @@ export function createObserveDocument({
client: SanityClient
mutationChannel: Observable<WelcomeEvent | MutationEvent>
}) {
function batchFetchDocuments(ids: string[][]) {
return client.observable
.fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'})
.pipe(map((result) => ids.map((id) => result.find(id))))
}
const getBatchFetcher = memoize(
function getBatchFetcher(apiConfig: {dataset: string; projectId: string}) {
const _client = client.withConfig(apiConfig)

function batchFetchDocuments(ids: [string][]) {
return _client.observable
.fetch(`*[_id in $ids]`, {ids: uniq(ids.flat())}, {tag: 'preview.observe-document'})
.pipe(
// eslint-disable-next-line max-nested-callbacks
map((result) => ids.map(([id]) => result.find((r: {_id: string}) => r._id === id))),
)
}
return debounceCollect(batchFetchDocuments, 100)
},
(apiConfig) => apiConfig.dataset + apiConfig.projectId,
)

const fetchDocument = debounceCollect(batchFetchDocuments, 10)
const MEMO: Record<string, Observable<SanityDocument | undefined>> = {}

return function observeDocument(id: string) {
function observeDocument(id: string, apiConfig?: ApiConfig) {
const _apiConfig = apiConfig || {
dataset: client.config().dataset!,
projectId: client.config().projectId!,
}
const fetchDocument = getBatchFetcher(_apiConfig)
return mutationChannel.pipe(
concatMap((event) => {
if (event.type === 'welcome') {
return fetchDocument(id).pipe(map((document) => ({type: 'sync', document})))
return fetchDocument(id).pipe(map((document) => ({type: 'sync' as const, document})))
}
return event.documentId === id ? of(event) : EMPTY
}),
scan((current, event) => {
scan((current: SanityDocument | undefined, event) => {
if (event.type === 'sync') {
return event.document
}
if (event.type === 'mutation') {
return current
return applyMutationEvent(current, event)
}
//@ts-expect-error - this should never happen
throw new Error(`Unexpected event type: "${event.type}"`)
}, undefined),
)
}
return function memoizedObserveDocument(id: string, apiConfig?: ApiConfig) {
const key = apiConfig ? `${id}-${JSON.stringify(apiConfig)}` : id
if (!(key in MEMO)) {
MEMO[key] = observeDocument(key, apiConfig).pipe(
finalize(() => delete MEMO[key]),
shareReplay({bufferSize: 1, refCount: true}),
)
}
return MEMO[id]
}
}

function applyMutationEvent(current: SanityDocument | undefined, event: MutationEvent) {
if (event.previousRev !== current?._rev) {
console.warn('Document out of sync, skipping mutation')
return current
}
if (!event.effects) {
throw new Error(
'Mutation event is missing effects. Is the listener set up with effectFormat=mendoza?',
)
}
const next = applyMendozaPatch(current, event.effects.apply)
return {...next, _rev: event.resultRev}
}
52 changes: 44 additions & 8 deletions packages/sanity/src/core/preview/documentPreviewStore.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {type SanityClient} from '@sanity/client'
import {type PrepareViewOptions, type SanityDocument} from '@sanity/types'
import {type Observable} from 'rxjs'
import {pick} from 'lodash'
import {combineLatest, type Observable} from 'rxjs'
import {distinctUntilChanged, map} from 'rxjs/operators'

import {isRecord} from '../util'
import {createPreviewAvailabilityObserver} from './availability'
import {createGlobalListener} from './createGlobalListener'
import {createObserveDocument} from './createObserveDocument'
import {createPathObserver} from './createPathObserver'
import {createPreviewObserver} from './createPreviewObserver'
import {create_preview_documentPair} from './documentPair'
Expand All @@ -31,6 +33,11 @@ export type ObserveForPreviewFn = (
) => Observable<PreparedSnapshot>

/**
* The document preview store supports subscribing to content for previewing purposes.
* Documents observed by this store will be kept in sync and receive real-time updates from all collaborators,
* but has no support for optimistic updates, so any local edits will require a server round-trip before becoming visible,
* which means this store is less suitable for real-time editing scenarios.
*
* @hidden
* @beta */
export interface DocumentPreviewStore {
Expand All @@ -51,28 +58,56 @@ export interface DocumentPreviewStore {
id: string,
paths: PreviewPath[],
) => Observable<DraftsModelDocument<T>>

/**
* Observe a complete document with the given ID
* @hidden
* @beta
*/
unstable_observeDocument: (id: string) => Observable<SanityDocument | undefined>
/**
* Observe a list of complete documents with the given IDs
* @hidden
* @beta
*/
unstable_observeDocuments: (ids: string[]) => Observable<(SanityDocument | undefined)[]>
}

/** @internal */
export interface DocumentPreviewStoreOptions {
client: SanityClient
}

/** @internal
* Should the preview system fetch partial documents or full documents?
* Setting this to false will end up fetching full documents for everything that's currently being previewed in the studio
* This comes with an extra memory and initial transfer cost, but gives faster updating previews and less likelyhood of displaying
* out-of-date previews as documents will be kept in sync by applying mendosa patches, insted of re-fetching preview queries
* */
const FETCH_PARTIAL_DOCUMENTS = false

/** @internal */
export function createDocumentPreviewStore({
client,
}: DocumentPreviewStoreOptions): DocumentPreviewStore {
const versionedClient = client.withConfig({apiVersion: '1'})
const globalListener = createGlobalListener(versionedClient)

const invalidationChannel = globalListener.pipe(
map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)),
)

const {observeFields} = createObserveFields({
client: versionedClient,
invalidationChannel,
})
const observeDocument = createObserveDocument({client, mutationChannel: globalListener})

function getObserveFields() {
if (FETCH_PARTIAL_DOCUMENTS) {
return createObserveFields({client: versionedClient, invalidationChannel})
}
return function observeFields(id: string, fields: string[], apiConfig?: ApiConfig) {
return observeDocument(id, apiConfig).pipe(map((doc) => pick(doc, fields)))
}
}

const observeFields = getObserveFields()

const {observePaths} = createPathObserver({observeFields})

Expand All @@ -94,14 +129,15 @@ export function createDocumentPreviewStore({
const {observePathsDocumentPair} = create_preview_documentPair(versionedClient, observePaths)

// @todo: explain why the API is like this now, and that it should not be like this in the future!
const observeDocument = createObserveDocument({client, mutationChannel: globalListener})

return {
observePaths,
observeForPreview,
observeDocumentTypeFromId,

// eslint-disable-next-line camelcase
unstable_observeDocument: observeDocument,
unstable_observeDocuments: (ids: string[]) =>
combineLatest(ids.map((id) => observeDocument(id))),
unstable_observeDocumentPairAvailability: observeDocumentPairAvailability,
unstable_observePathsDocumentPair: observePathsDocumentPair,
}
Expand Down
3 changes: 1 addition & 2 deletions packages/sanity/src/core/preview/observeFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ export function createObserveFields(options: {
)
}

// API
return {observeFields: cachedObserveFields}
return cachedObserveFields

function pickFrom(objects: Record<string, any>[], fields: string[]) {
return [...INCLUDE_FIELDS, ...fields].reduce((result, fieldName) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/sanity/src/core/preview/utils/applyMendozaPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {type SanityDocument} from '@sanity/types'
import {applyPatch, type RawPatch} from 'mendoza'

function omitRev(document: SanityDocument | undefined) {
if (document === undefined) {
return undefined
}
const {_rev, ...doc} = document
return doc
}

export function applyMendozaPatch(
document: SanityDocument | undefined,
patch: RawPatch,
): SanityDocument | undefined {
const next = applyPatch(omitRev(document), patch)
return next === null ? undefined : next
}

0 comments on commit 598a960

Please sign in to comment.