Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: preserve visited documents in documents-store [WIP] #7537

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/sanity/src/core/preview/createGlobalListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function createGlobalListener(client: SanityClient) {
includePreviousRevision: false,
includeMutations: false,
visibility: 'query',
effectFormat: 'mendoza',
tag: 'preview.global',
},
)
Expand Down
87 changes: 87 additions & 0 deletions packages/sanity/src/core/preview/createObserveDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client'
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({
mutationChannel,
client,
}: {
client: SanityClient
mutationChannel: Observable<WelcomeEvent | MutationEvent>
}) {
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 MEMO: Record<string, Observable<SanityDocument | undefined>> = {}

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' as const, document})))
}
return event.documentId === id ? of(event) : EMPTY
}),
scan((current: SanityDocument | undefined, event) => {
if (event.type === 'sync') {
return event.document
}
if (event.type === 'mutation') {
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(id, apiConfig).pipe(
finalize(() => delete MEMO[key]),
shareReplay({bufferSize: 1, refCount: true}),
)
}
return MEMO[key]
}
}

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)
// next will be undefined in case of deletion
return next ? {...next, _rev: event.resultRev} : undefined
}
20 changes: 19 additions & 1 deletion packages/sanity/src/core/preview/documentPreviewStore.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {type MutationEvent, type SanityClient, type WelcomeEvent} from '@sanity/client'
import {type PrepareViewOptions, type SanityDocument} from '@sanity/types'
import {type Observable} from 'rxjs'
import {combineLatest, type Observable} from 'rxjs'
import {distinctUntilChanged, filter, 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 {createObservePathsDocumentPair} from './documentPair'
Expand Down Expand Up @@ -56,6 +57,18 @@ 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 */
Expand All @@ -79,6 +92,8 @@ export function createDocumentPreviewStore({
map((event) => (event.type === 'welcome' ? {type: 'connected' as const} : event)),
)

const observeDocument = createObserveDocument({client, mutationChannel: globalListener})

const observeFields = createObserveFields({client: versionedClient, invalidationChannel})
const observePaths = createPathObserver({observeFields})

Expand Down Expand Up @@ -110,6 +125,9 @@ export function createDocumentPreviewStore({
observeForPreview,
observeDocumentTypeFromId,

unstable_observeDocument: observeDocument,
unstable_observeDocuments: (ids: string[]) =>
combineLatest(ids.map((id) => observeDocument(id))),
unstable_observeDocumentPairAvailability: observeDocumentPairAvailability,
unstable_observePathsDocumentPair: observePathsDocumentPair,
}
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
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {type SanityClient} from '@sanity/client'
import {type SanityDocument, type Schema} from '@sanity/types'
import {combineLatest, type Observable} from 'rxjs'
import {map, publishReplay, refCount, startWith, switchMap} from 'rxjs/operators'
import {map, publishReplay, refCount, startWith, switchMap, take} from 'rxjs/operators'

import {type IdPair, type PendingMutationsEvent} from '../types'
import {memoize} from '../utils/createMemoizer'
Expand Down Expand Up @@ -38,36 +38,49 @@ export const editState = memoize(
},
idPair: IdPair,
typeName: string,
visited$: Observable<(SanityDocument | undefined)[]>,
): Observable<EditStateFor> => {
const liveEdit = isLiveEditEnabled(ctx.schema, typeName)
return snapshotPair(ctx.client, idPair, typeName, ctx.serverActionsEnabled).pipe(
switchMap((versions) =>
combineLatest([
versions.draft.snapshots$,
versions.published.snapshots$,
versions.transactionsPendingEvents$.pipe(
map((ev: PendingMutationsEvent) => (ev.phase === 'begin' ? LOCKED : NOT_LOCKED)),
startWith(NOT_LOCKED),
return visited$.pipe(
take(1),
map((visited) => {
return {
draft: visited.find((doc) => doc?._id === idPair.draftId) || null,
published: visited.find((doc) => doc?._id === idPair.publishedId) || null,
}
}),
switchMap((visitedPair) => {
return snapshotPair(ctx.client, idPair, typeName, ctx.serverActionsEnabled).pipe(
switchMap((versions) =>
combineLatest([
versions.draft.snapshots$,
versions.published.snapshots$,
versions.transactionsPendingEvents$.pipe(
// eslint-disable-next-line max-nested-callbacks
map((ev: PendingMutationsEvent) => (ev.phase === 'begin' ? LOCKED : NOT_LOCKED)),
startWith(NOT_LOCKED),
),
]),
),
]),
),
map(([draftSnapshot, publishedSnapshot, transactionSyncLock]) => ({
id: idPair.publishedId,
type: typeName,
draft: draftSnapshot,
published: publishedSnapshot,
liveEdit,
ready: true,
transactionSyncLock,
})),
startWith({
id: idPair.publishedId,
type: typeName,
draft: null,
published: null,
liveEdit,
ready: false,
transactionSyncLock: null,
map(([draftSnapshot, publishedSnapshot, transactionSyncLock]) => ({
id: idPair.publishedId,
type: typeName,
draft: draftSnapshot,
published: publishedSnapshot,
liveEdit,
ready: true,
transactionSyncLock,
})),
startWith({
id: idPair.publishedId,
type: typeName,
draft: visitedPair.draft,
published: visitedPair.published,
liveEdit,
ready: false,
transactionSyncLock: null,
}),
)
}),
publishReplay(1),
refCount(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {type SanityClient} from '@sanity/client'
import {type Schema} from '@sanity/types'
import {type SanityDocument, type Schema} from '@sanity/types'
import {omit} from 'lodash'
import {asyncScheduler, type Observable} from 'rxjs'
import {distinctUntilChanged, map, shareReplay, throttleTime} from 'rxjs/operators'
Expand Down Expand Up @@ -34,8 +34,9 @@ export const validation = memoize(
},
{draftId, publishedId}: IdPair,
typeName: string,
visited$: Observable<(SanityDocument | undefined)[]>,
): Observable<ValidationStatus> => {
const document$ = editState(ctx, {draftId, publishedId}, typeName).pipe(
const document$ = editState(ctx, {draftId, publishedId}, typeName, visited$).pipe(
map(({draft, published}) => draft || published),
throttleTime(DOC_UPDATE_DELAY, asyncScheduler, {trailing: true}),
distinctUntilChanged((prev, next) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './document-pair/operationEvents'
import {type OperationsAPI} from './document-pair/operations'
import {validation} from './document-pair/validation'
import {getVisitedDocuments} from './getVisitedDocuments'
import {getInitialValueStream, type InitialValueMsg, type InitialValueOptions} from './initialValue'
import {listenQuery, type ListenQueryOptions} from './listenQuery'
import {resolveTypeForDocument} from './resolveTypeForDocument'
Expand Down Expand Up @@ -100,6 +101,10 @@ export function createDocumentStore({
const observeDocumentPairAvailability =
documentPreviewStore.unstable_observeDocumentPairAvailability

const visitedDocuments = getVisitedDocuments({
observeDocuments: documentPreviewStore.unstable_observeDocuments,
})

// Note that we're both passing a shared `client` here which is used by the
// internal operations, and a `getClient` method that we expose to user-land
// for things like validations
Expand Down Expand Up @@ -156,7 +161,14 @@ export function createDocumentStore({
return editOperations(ctx, getIdPairFromPublished(publishedId), type)
},
editState(publishedId, type) {
return editState(ctx, getIdPairFromPublished(publishedId), type)
const edit = editState(
ctx,
getIdPairFromPublished(publishedId),
type,
visitedDocuments.visited$,
)
visitedDocuments.add(publishedId)
return edit
},
operationEvents(publishedId, type) {
return operationEvents({
Expand All @@ -178,7 +190,7 @@ export function createDocumentStore({
)
},
validation(publishedId, type) {
return validation(ctx, getIdPairFromPublished(publishedId), type)
return validation(ctx, getIdPairFromPublished(publishedId), type, visitedDocuments.visited$)
},
},
}
Expand Down
Loading
Loading