Skip to content

Commit 7b9b556

Browse files
authored
fix(preview): allow null as valid cache/memo value for preview fields (#7551)
* refactor(preview): explicitly type the interface required by observeFields * test(preview): add a test for observePreview memo * fix(sanity): allow "null" as valid cache/memo value for preview fields
1 parent 7aed0c0 commit 7b9b556

File tree

2 files changed

+68
-6
lines changed

2 files changed

+68
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {describe, expect, it} from '@jest/globals'
2+
import {firstValueFrom, of, Subject} from 'rxjs'
3+
import {take, tap} from 'rxjs/operators'
4+
5+
import {type ClientLike, createObserveFields} from '../observeFields'
6+
import {type InvalidationChannelEvent} from '../types'
7+
8+
describe('observeFields', () => {
9+
it('should cache the last known value and emit sync', async () => {
10+
const client: ClientLike = {
11+
observable: {
12+
fetch: (query) => {
13+
expect(query).toEqual('[*[_id in ["foo"]][0...1]{_id,_rev,_type,bar}][0...1]')
14+
return of([
15+
[
16+
// no result
17+
],
18+
])
19+
},
20+
},
21+
withConfig: () => client,
22+
}
23+
24+
const invalidationChannel = new Subject<InvalidationChannelEvent>()
25+
const observeFields = createObserveFields({
26+
invalidationChannel,
27+
client,
28+
})
29+
const first = firstValueFrom(observeFields('foo', ['bar']).pipe(take(1)))
30+
invalidationChannel.next({type: 'connected'})
31+
32+
expect(await first).toMatchInlineSnapshot(`null`)
33+
34+
// After we got first value from server and it turned out to be `null`, we should have `null` as the memoized sync value
35+
let syncValue = undefined
36+
observeFields('foo', ['bar'])
37+
.pipe(
38+
tap((value) => {
39+
syncValue = value
40+
}),
41+
take(1),
42+
)
43+
.subscribe()
44+
.unsubscribe()
45+
expect(syncValue).toBe(null)
46+
})
47+
})

packages/sanity/src/core/preview/observeFields.ts

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {type SanityClient} from '@sanity/client'
21
import {difference, flatten, memoize} from 'lodash'
32
import {
43
combineLatest,
@@ -46,14 +45,29 @@ type Cache = {
4645
[id: string]: CachedFieldObserver[]
4746
}
4847

48+
/**
49+
* Note: this should be the minimal interface createObserveFields needs to function
50+
* It should be kept compatible with the Sanity Client
51+
*/
52+
export interface ClientLike {
53+
withConfig(config: ApiConfig): ClientLike
54+
observable: {
55+
fetch: (
56+
query: string,
57+
params: Record<string, string>,
58+
options: {tag: string},
59+
) => Observable<unknown>
60+
}
61+
}
62+
4963
/**
5064
* Creates a function that allows observing individual fields on a document.
5165
* It will automatically debounce and batch requests, and maintain an in-memory cache of the latest field values
5266
* @param options - Options to use when creating the observer
5367
* @internal
5468
*/
5569
export function createObserveFields(options: {
56-
client: SanityClient
70+
client: ClientLike
5771
invalidationChannel: Observable<InvalidationChannelEvent>
5872
}) {
5973
const {client: currentDatasetClient, invalidationChannel} = options
@@ -63,11 +77,11 @@ export function createObserveFields(options: {
6377
)
6478
}
6579

66-
function fetchAllDocumentPathsWith(client: SanityClient) {
80+
function fetchAllDocumentPathsWith(client: ClientLike) {
6781
return function fetchAllDocumentPath(selections: Selection[]) {
6882
const combinedSelections = combineSelections(selections)
6983
return client.observable
70-
.fetch(toQuery(combinedSelections), {}, {tag: 'preview.document-paths'} as any)
84+
.fetch(toQuery(combinedSelections), {}, {tag: 'preview.document-paths'})
7185
.pipe(map((result: any) => reassemble(result, combinedSelections)))
7286
}
7387
}
@@ -140,9 +154,10 @@ export function createObserveFields(options: {
140154
fields: FieldName[],
141155
apiConfig?: ApiConfig,
142156
): CachedFieldObserver {
143-
let latest: T | null = null
157+
// 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)
158+
let latest: T | undefined | null = undefined
144159
const changes$ = merge(
145-
defer(() => (latest === null ? EMPTY : observableOf(latest))),
160+
defer(() => (latest === undefined ? EMPTY : observableOf(latest))),
146161
(apiConfig
147162
? (crossDatasetListenFields(id, fields, apiConfig) as any)
148163
: currentDatasetListenFields(id, fields)) as Observable<T>,

0 commit comments

Comments
 (0)