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

refactor(preview): extract global listener, refactor preview APIs and improve typings #7360

Merged
merged 3 commits into from
Aug 20, 2024
Merged
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
50 changes: 23 additions & 27 deletions packages/sanity/src/core/preview/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,34 +67,10 @@ function mutConcat<T>(array: T[], chunks: T[]) {
return array
}

export function create_preview_availability(
export function createPreviewAvailabilityObserver(
versionedClient: SanityClient,
observePaths: ObservePathsFn,
): {
observeDocumentPairAvailability(id: string): Observable<DraftsModelDocumentAvailability>
} {
/**
* Returns an observable of metadata for a given drafts model document
*/
function observeDocumentPairAvailability(
id: string,
): Observable<DraftsModelDocumentAvailability> {
const draftId = getDraftId(id)
const publishedId = getPublishedId(id)
return combineLatest([
observeDocumentAvailability(draftId),
observeDocumentAvailability(publishedId),
]).pipe(
distinctUntilChanged(shallowEquals),
map(([draftReadability, publishedReadability]) => {
return {
draft: draftReadability,
published: publishedReadability,
}
}),
)
}

): (id: string) => Observable<DraftsModelDocumentAvailability> {
/**
* Observable of metadata for the document with the given id
* If we can't read a document it is either because it's not readable or because it doesn't exist
Expand Down Expand Up @@ -158,5 +134,25 @@ export function create_preview_availability(
})
}

return {observeDocumentPairAvailability}
/**
* Returns an observable of metadata for a given drafts model document
*/
return function observeDocumentPairAvailability(
id: string,
): Observable<DraftsModelDocumentAvailability> {
const draftId = getDraftId(id)
const publishedId = getPublishedId(id)
return combineLatest([
observeDocumentAvailability(draftId),
observeDocumentAvailability(publishedId),
]).pipe(
distinctUntilChanged(shallowEquals),
map(([draftReadability, publishedReadability]) => {
return {
draft: draftReadability,
published: publishedReadability,
}
}),
)
}
}
6 changes: 6 additions & 0 deletions packages/sanity/src/core/preview/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import {type PreviewValue} from '@sanity/types'
export const INCLUDE_FIELDS_QUERY = ['_id', '_rev', '_type']
export const INCLUDE_FIELDS = [...INCLUDE_FIELDS_QUERY, '_key']

/**
* How long to wait after the last subscriber has unsubscribed before resetting the observable and disconnecting the listener
* We want to keep the listener alive for a short while after the last subscriber has unsubscribed to avoid unnecessary reconnects
*/
export const LISTENER_RESET_DELAY = 10_000

export const AVAILABILITY_READABLE = {
available: true,
reason: 'READABLE',
Expand Down
31 changes: 31 additions & 0 deletions packages/sanity/src/core/preview/createGlobalListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {type SanityClient} from '@sanity/client'
import {timer} from 'rxjs'

import {LISTENER_RESET_DELAY} from './constants'
import {shareReplayLatest} from './utils/shareReplayLatest'

/**
* @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) {
return client
.listen(
'*[!(_id in path("_.**"))]',
{},
{
events: ['welcome', 'mutation', 'reconnect'],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reconnect is filtered out downstream for now, but we might want to consider using this at a later point

includeResult: false,
includePreviousRevision: false,
includeMutations: false,
visibility: 'query',
tag: 'preview.global',
},
)
.pipe(
shareReplayLatest({
predicate: (event) => event.type === 'welcome' || event.type === 'reconnect',
resetOnRefCountZero: () => timer(LISTENER_RESET_DELAY),
}),
)
}
26 changes: 15 additions & 11 deletions packages/sanity/src/core/preview/createPathObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,20 @@ function normalizePaths(path: (FieldName | PreviewPath)[]): PreviewPath[] {
)
}

export function createPathObserver(context: {observeFields: ObserveFieldsFn}) {
const {observeFields} = context

return {
observePaths(
value: Previewable,
paths: (FieldName | PreviewPath)[],
apiConfig?: ApiConfig,
): Observable<Record<string, unknown> | null> {
return observePaths(value, normalizePaths(paths), observeFields, apiConfig)
},
/**
* Creates a function that allows observing nested paths on a document.
* If the path includes a reference, the reference will be "followed", allowing for selecting paths within the referenced document.
* @param options - Requires a function that can observe fields on a document
* @internal
*/
export function createPathObserver(options: {observeFields: ObserveFieldsFn}) {
const {observeFields} = options

return (
value: Previewable,
paths: (FieldName | PreviewPath)[],
apiConfig?: ApiConfig,
): Observable<Record<string, unknown> | null> => {
return observePaths(value, normalizePaths(paths), observeFields, apiConfig)
}
}
51 changes: 29 additions & 22 deletions packages/sanity/src/core/preview/createPreviewObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import {
type PrepareViewOptions,
} from '@sanity/types'
import {isPlainObject} from 'lodash'
import {type Observable, of as observableOf} from 'rxjs'
import {type Observable, of} from 'rxjs'
import {map, switchMap} from 'rxjs/operators'

import {type ObserveForPreviewFn} from './documentPreviewStore'
import {
type ApiConfig,
type ObserveDocumentTypeFromIdFn,
type ObservePathsFn,
type PreparedSnapshot,
type Previewable,
type PreviewableType,
type PreviewPath,
} from './types'
import {getPreviewPaths} from './utils/getPreviewPaths'
import {invokePrepare, prepareForPreview} from './utils/prepareForPreview'
Expand All @@ -26,29 +28,30 @@ function isReference(value: unknown): value is {_ref: string} {
return isPlainObject(value)
}

// Takes a value and its type and prepares a snapshot for it that can be passed to a preview component
/**
* Takes a value and its type and prepares a snapshot for it that can be passed to a preview component
* @internal
*/
export function createPreviewObserver(context: {
observeDocumentTypeFromId: (id: string, apiConfig?: ApiConfig) => Observable<string | undefined>
observePaths: (value: Previewable, paths: PreviewPath[], apiConfig?: ApiConfig) => any
}): (
value: Previewable,
type: PreviewableType,
viewOptions?: PrepareViewOptions,
apiConfig?: ApiConfig,
) => Observable<PreparedSnapshot> {
observeDocumentTypeFromId: ObserveDocumentTypeFromIdFn
observePaths: ObservePathsFn
}): ObserveForPreviewFn {
const {observeDocumentTypeFromId, observePaths} = context

return function observeForPreview(
value: Previewable,
type: PreviewableType,
viewOptions?: PrepareViewOptions,
apiConfig?: ApiConfig,
options: {
viewOptions?: PrepareViewOptions
apiConfig?: ApiConfig
} = {},
): Observable<PreparedSnapshot> {
const {viewOptions = {}, apiConfig} = options
if (isCrossDatasetReferenceSchemaType(type)) {
// if the value is of type crossDatasetReference, but has no _ref property, we cannot prepare any value for the preview
// and the most appropriate thing to do is to return `undefined` for snapshot
if (!isCrossDatasetReference(value)) {
return observableOf({snapshot: undefined})
return of({snapshot: undefined})
}

const refApiConfig = {projectId: value._projectId, dataset: value._dataset}
Expand All @@ -57,32 +60,36 @@ export function createPreviewObserver(context: {
switchMap((typeName) => {
if (typeName) {
const refType = type.to.find((toType) => toType.type === typeName)
return observeForPreview(value, refType as any, {}, refApiConfig)
if (refType) {
return observeForPreview(value, refType, {apiConfig: refApiConfig, viewOptions})
}
}
return observableOf({snapshot: undefined})
return of({snapshot: undefined})
}),
)
}
if (isReferenceSchemaType(type)) {
// if the value is of type reference, but has no _ref property, we cannot prepare any value for the preview
// and the most appropriate thing to do is to return `undefined` for snapshot
if (!isReference(value)) {
return observableOf({snapshot: undefined})
return of({snapshot: undefined})
}
// Previewing references actually means getting the referenced value,
// and preview using the preview config of its type
// todo: We need a way of knowing the type of the referenced value by looking at the reference record alone
// We do this since there's no way of knowing the type of the referenced value by looking at the reference value alone
return observeDocumentTypeFromId(value._ref).pipe(
switchMap((typeName) => {
if (typeName) {
const refType = type.to.find((toType) => toType.name === typeName)
return observeForPreview(value, refType as any)
if (refType) {
return observeForPreview(value, refType)
}
}
// todo: in case we can't read the document type, we can figure out the reason why e.g. whether it's because
// the document doesn't exist or it's not readable due to lack of permission.
// We can use the "observeDocumentAvailability" function
// for this, but currently not sure if needed
return observableOf({snapshot: undefined})
return of({snapshot: undefined})
}),
)
}
Expand All @@ -91,7 +98,7 @@ export function createPreviewObserver(context: {
return observePaths(value, paths, apiConfig).pipe(
map((snapshot) => ({
type: type,
snapshot: snapshot && prepareForPreview(snapshot, type as any, viewOptions),
snapshot: snapshot ? prepareForPreview(snapshot, type, viewOptions) : null,
})),
)
}
Expand All @@ -100,7 +107,7 @@ export function createPreviewObserver(context: {
// the SchemaType doesn't have a `select` field. The schema compiler
// provides a default `preview` implementation for `object`s, `image`s,
// `file`s, and `document`s
return observableOf({
return of({
type,
snapshot:
value && isRecord(value) ? invokePrepare(type, value, viewOptions).returnValue : null,
Expand Down
34 changes: 15 additions & 19 deletions packages/sanity/src/core/preview/documentPair.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
import {type SanityClient} from '@sanity/client'
import {type SanityDocument} from '@sanity/types'
import {combineLatest, type Observable, of} from 'rxjs'
import {map, switchMap} from 'rxjs/operators'

import {getIdPair, isRecord} from '../util'
import {create_preview_availability} from './availability'
import {type DraftsModelDocument, type ObservePathsFn, type PreviewPath} from './types'
import {
type DraftsModelDocument,
type ObserveDocumentAvailabilityFn,
type ObservePathsFn,
type PreviewPath,
} from './types'

export function create_preview_documentPair(
versionedClient: SanityClient,
observePaths: ObservePathsFn,
): {
observePathsDocumentPair: <T extends SanityDocument = SanityDocument>(
id: string,
paths: PreviewPath[],
) => Observable<DraftsModelDocument<T>>
} {
const {observeDocumentPairAvailability} = create_preview_availability(
versionedClient,
observePaths,
)
export function createObservePathsDocumentPair(options: {
observeDocumentPairAvailability: ObserveDocumentAvailabilityFn
observePaths: ObservePathsFn
}): <T extends SanityDocument = SanityDocument>(
id: string,
paths: PreviewPath[],
) => Observable<DraftsModelDocument<T>> {
const {observeDocumentPairAvailability, observePaths} = options

const ALWAYS_INCLUDED_SNAPSHOT_PATHS: PreviewPath[] = [['_updatedAt'], ['_createdAt'], ['_type']]

return {observePathsDocumentPair}

function observePathsDocumentPair<T extends SanityDocument = SanityDocument>(
return function observePathsDocumentPair<T extends SanityDocument = SanityDocument>(
id: string,
paths: PreviewPath[],
): Observable<DraftsModelDocument<T>> {
Expand Down
Loading
Loading