diff --git a/packages/sanity/src/core/preview/__test__/observeFields.test.ts b/packages/sanity/src/core/preview/__test__/observeFields.test.ts new file mode 100644 index 00000000000..63d863187fd --- /dev/null +++ b/packages/sanity/src/core/preview/__test__/observeFields.test.ts @@ -0,0 +1,47 @@ +import {describe, expect, it} from '@jest/globals' +import {firstValueFrom, of, Subject} from 'rxjs' +import {take, tap} from 'rxjs/operators' + +import {type ClientLike, createObserveFields} from '../observeFields' +import {type InvalidationChannelEvent} from '../types' + +describe('observeFields', () => { + it('should cache the last known value and emit sync', async () => { + const client: ClientLike = { + observable: { + fetch: (query) => { + expect(query).toEqual('[*[_id in ["foo"]][0...1]{_id,_rev,_type,bar}][0...1]') + return of([ + [ + // no result + ], + ]) + }, + }, + withConfig: () => client, + } + + const invalidationChannel = new Subject() + const observeFields = createObserveFields({ + invalidationChannel, + client, + }) + const first = firstValueFrom(observeFields('foo', ['bar']).pipe(take(1))) + invalidationChannel.next({type: 'connected'}) + + expect(await first).toMatchInlineSnapshot(`null`) + + // After we got first value from server and it turned out to be `null`, we should have `null` as the memoized sync value + let syncValue = undefined + observeFields('foo', ['bar']) + .pipe( + tap((value) => { + syncValue = value + }), + take(1), + ) + .subscribe() + .unsubscribe() + expect(syncValue).toBe(null) + }) +}) diff --git a/packages/sanity/src/core/preview/observeFields.ts b/packages/sanity/src/core/preview/observeFields.ts index 91c99bcf816..95cf5500d9c 100644 --- a/packages/sanity/src/core/preview/observeFields.ts +++ b/packages/sanity/src/core/preview/observeFields.ts @@ -1,4 +1,3 @@ -import {type SanityClient} from '@sanity/client' import {difference, flatten, memoize} from 'lodash' import { combineLatest, @@ -46,6 +45,21 @@ type Cache = { [id: string]: CachedFieldObserver[] } +/** + * Note: this should be the minimal interface createObserveFields needs to function + * It should be kept compatible with the Sanity Client + */ +export interface ClientLike { + withConfig(config: ApiConfig): ClientLike + observable: { + fetch: ( + query: string, + params: Record, + options: {tag: string}, + ) => Observable + } +} + /** * Creates a function that allows observing individual fields on a document. * It will automatically debounce and batch requests, and maintain an in-memory cache of the latest field values @@ -53,7 +67,7 @@ type Cache = { * @internal */ export function createObserveFields(options: { - client: SanityClient + client: ClientLike invalidationChannel: Observable }) { const {client: currentDatasetClient, invalidationChannel} = options @@ -63,11 +77,11 @@ export function createObserveFields(options: { ) } - function fetchAllDocumentPathsWith(client: SanityClient) { + function fetchAllDocumentPathsWith(client: ClientLike) { return function fetchAllDocumentPath(selections: Selection[]) { const combinedSelections = combineSelections(selections) return client.observable - .fetch(toQuery(combinedSelections), {}, {tag: 'preview.document-paths'} as any) + .fetch(toQuery(combinedSelections), {}, {tag: 'preview.document-paths'}) .pipe(map((result: any) => reassemble(result, combinedSelections))) } } @@ -140,9 +154,10 @@ export function createObserveFields(options: { fields: FieldName[], apiConfig?: ApiConfig, ): CachedFieldObserver { - let latest: T | null = null + // Note: `undefined` means the memo has not been set, while `null` means the memo is explicitly set to null (e.g. we did fetch, but got null back) + let latest: T | undefined | null = undefined const changes$ = merge( - defer(() => (latest === null ? EMPTY : observableOf(latest))), + defer(() => (latest === undefined ? EMPTY : observableOf(latest))), (apiConfig ? (crossDatasetListenFields(id, fields, apiConfig) as any) : currentDatasetListenFields(id, fields)) as Observable,