@sanity/sdk-react 3.9.1 → 3.9.2-next.20261013215442

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk-react",
3
- "version": "3.9.1",
3
+ "version": "3.9.2-next.20261013215442",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "@module-federation/runtime": "^2.9.2",
46
46
  "@sanity/client": "^8.9.0",
47
47
  "@sanity/message-protocol": "^0.24.0",
48
- "@sanity/sdk": "3.9.1",
48
+ "@sanity/sdk": "3.9.2-next.20261013215442",
49
49
  "@sanity/types": "^6.17.0",
50
50
  "react-compiler-runtime": "^1.0.0",
51
51
  "react-error-boundary": "^6.1.6",
@@ -77,7 +77,7 @@
77
77
  "react-dom": "^19.3.0",
78
78
  "rolldown": "^1.2.12",
79
79
  "typescript": "^6.0.3",
80
- "vite": "^8.3.3",
80
+ "vite": "^8.3.4",
81
81
  "vitest": "^5.0.3",
82
82
  "xstate": "5.33.2"
83
83
  },
@@ -1,6 +1,7 @@
1
1
  import {type DocumentHandle, getProjectionState, resolveProjection} from '@sanity/sdk'
2
2
  import {act, render, screen} from '@testing-library/react'
3
3
  import {useRef} from 'react'
4
+ import {NEVER} from 'rxjs'
4
5
  import {type Mock} from 'vitest'
5
6
 
6
7
  import {ResourceProvider} from '../../context/ResourceProvider'
@@ -27,13 +28,11 @@ beforeAll(() => {
27
28
  // Mock the projection store
28
29
  vi.mock('@sanity/sdk', async (importOriginal) => {
29
30
  const actual = await importOriginal<typeof import('@sanity/sdk')>()
30
- const getCurrent = vi.fn()
31
- const subscribe = vi.fn()
32
31
 
33
32
  return {
34
33
  ...actual,
35
34
  resolveProjection: vi.fn(),
36
- getProjectionState: vi.fn().mockReturnValue({getCurrent, subscribe}),
35
+ getProjectionState: vi.fn(),
37
36
  }
38
37
  })
39
38
 
@@ -65,14 +64,11 @@ describe('useDocumentProjection', () => {
65
64
  let subscribe: Mock
66
65
 
67
66
  beforeEach(() => {
68
- // @ts-expect-error mock does not need param
69
- getCurrent = getProjectionState().getCurrent as Mock
70
- // @ts-expect-error mock does not need param
71
- subscribe = getProjectionState().subscribe as Mock
72
-
73
- // Reset all mocks between tests
74
- getCurrent.mockReset()
75
- subscribe.mockReset()
67
+ getCurrent = vi.fn()
68
+ subscribe = vi.fn()
69
+ vi.mocked(getProjectionState)
70
+ .mockReset()
71
+ .mockReturnValue({getCurrent, subscribe, observable: NEVER})
76
72
  mockIntersectionObserver.mockReset()
77
73
  })
78
74
 
@@ -189,6 +185,12 @@ describe('useDocumentProjection', () => {
189
185
 
190
186
  expect(screen.getByText('Resolved Title')).toBeInTheDocument()
191
187
  expect(screen.getByText('Resolved Description')).toBeInTheDocument()
188
+
189
+ // Suspending must resolve the same store entry the hook reads
190
+ const resolveOptions = vi.mocked(resolveProjection).mock.calls.at(-1)?.[1]
191
+ expect(
192
+ vi.mocked(getProjectionState).mock.calls.some(([, options]) => options === resolveOptions),
193
+ ).toBe(true)
192
194
  })
193
195
 
194
196
  test('it handles environments without IntersectionObserver', async () => {
@@ -245,6 +247,136 @@ describe('useDocumentProjection', () => {
245
247
  expect(screen.getByText('Added Description')).toBeInTheDocument()
246
248
  })
247
249
 
250
+ test.each([
251
+ {
252
+ documentId: 'doc1',
253
+ documentType: 'exampleType',
254
+ resource: {projectId: 'test', dataset: 'test'},
255
+ },
256
+ {
257
+ resource: {dataset: 'test', projectId: 'test'},
258
+ ignored: 'unrelated data',
259
+ documentType: 'exampleType',
260
+ documentId: 'doc1',
261
+ },
262
+ ])(
263
+ 'it keeps its subscription across equivalent handle and resource values: %j',
264
+ async (document) => {
265
+ getCurrent.mockReturnValue({
266
+ data: {title: 'Title', description: 'Description'},
267
+ isPending: false,
268
+ })
269
+ const unsubscribe = vi.fn()
270
+ subscribe.mockReturnValue(unsubscribe)
271
+ vi.mocked(getProjectionState)
272
+ .mockClear()
273
+ .mockImplementation(() => ({
274
+ getCurrent,
275
+ subscribe: (...args) => subscribe(...args),
276
+ observable: NEVER,
277
+ }))
278
+ const {rerender} = render(
279
+ <ResourceProvider fallback={<div>Loading...</div>}>
280
+ <TestComponent
281
+ document={{...mockDocument, resource: {projectId: 'test', dataset: 'test'}}}
282
+ projection="{title, description}"
283
+ />
284
+ </ResourceProvider>,
285
+ )
286
+ await act(async () => {
287
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
288
+ })
289
+ expect(subscribe).toHaveBeenCalledTimes(1)
290
+ rerender(
291
+ <ResourceProvider fallback={<div>Loading...</div>}>
292
+ <TestComponent document={document} projection="{title, description}" />
293
+ </ResourceProvider>,
294
+ )
295
+ expect(getProjectionState).toHaveBeenCalledTimes(1)
296
+ expect(subscribe).toHaveBeenCalledTimes(1)
297
+ expect(unsubscribe).not.toHaveBeenCalled()
298
+
299
+ rerender(
300
+ <ResourceProvider fallback={<div>Loading...</div>}>
301
+ <TestComponent
302
+ document={{...mockDocument, documentId: 'doc2'}}
303
+ projection="{title, description}"
304
+ />
305
+ </ResourceProvider>,
306
+ )
307
+ expect(getProjectionState).toHaveBeenCalledTimes(2)
308
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
309
+ expect(vi.mocked(getProjectionState).mock.calls[1][1]).toMatchObject({documentId: 'doc2'})
310
+ },
311
+ )
312
+
313
+ test.each([
314
+ {resource: {projectId: 'other', dataset: 'test'}},
315
+ {resource: {projectId: 'test', dataset: 'other'}},
316
+ {resource: {mediaLibraryId: 'library'}},
317
+ {resource: {canvasId: 'canvas'}},
318
+ {perspective: 'published' as const},
319
+ ])(
320
+ 'it replaces the subscription when the resource or perspective changes: %j',
321
+ async (change) => {
322
+ getCurrent.mockReturnValue({
323
+ data: {title: 'Title', description: 'Description'},
324
+ isPending: false,
325
+ })
326
+ const unsubscribe = vi.fn()
327
+ subscribe.mockReturnValue(unsubscribe)
328
+ vi.mocked(getProjectionState).mockImplementation(() => ({
329
+ getCurrent,
330
+ subscribe: (...args) => subscribe(...args),
331
+ observable: NEVER,
332
+ }))
333
+ const {rerender} = render(
334
+ <ResourceProvider fallback={<div>Loading...</div>}>
335
+ <TestComponent document={mockDocument} projection="{title, description}" />
336
+ </ResourceProvider>,
337
+ )
338
+ await act(async () => {
339
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
340
+ })
341
+ expect(subscribe).toHaveBeenCalledTimes(1)
342
+ rerender(
343
+ <ResourceProvider fallback={<div>Loading...</div>}>
344
+ <TestComponent
345
+ document={{...mockDocument, ...change}}
346
+ projection="{title, description}"
347
+ />
348
+ </ResourceProvider>,
349
+ )
350
+ expect(getProjectionState).toHaveBeenCalledTimes(2)
351
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
352
+ expect(vi.mocked(getProjectionState).mock.calls[1][1]).toMatchObject(change)
353
+ await act(async () => {
354
+ intersectionObserverCallback([{isIntersecting: true} as IntersectionObserverEntry])
355
+ })
356
+ expect(subscribe).toHaveBeenCalledTimes(2)
357
+ },
358
+ )
359
+
360
+ test('it excludes unused params and extra document properties from the serialized handle', () => {
361
+ getCurrent.mockReturnValue({
362
+ data: {title: 'Title', description: 'Description'},
363
+ isPending: false,
364
+ })
365
+ const circular: {self?: unknown} = {}
366
+ circular.self = circular
367
+ const document = {...mockDocument, count: 1n, circular, params: {count: 1n, circular}}
368
+ vi.mocked(getProjectionState).mockClear()
369
+ render(
370
+ <ResourceProvider fallback={<div>Loading...</div>}>
371
+ <TestComponent document={document} projection="{title, description}" />
372
+ </ResourceProvider>,
373
+ )
374
+ expect(screen.getByText('Title')).toBeInTheDocument()
375
+ expect(vi.mocked(getProjectionState).mock.calls[0][1]).not.toHaveProperty('params')
376
+ expect(vi.mocked(getProjectionState).mock.calls[0][1]).not.toHaveProperty('count')
377
+ expect(vi.mocked(getProjectionState).mock.calls[0][1]).not.toHaveProperty('circular')
378
+ })
379
+
248
380
  test('it subscribes immediately when no ref is provided', async () => {
249
381
  getCurrent.mockReturnValue({
250
382
  data: {title: 'Title', description: 'Description'},
@@ -1,4 +1,10 @@
1
- import {getProjectionState, resolveProjection, type ResolveProjectionResult} from '@sanity/sdk'
1
+ import {
2
+ getProjectionState,
3
+ isDatasetResource,
4
+ isMediaLibraryResource,
5
+ resolveProjection,
6
+ type ResolveProjectionResult,
7
+ } from '@sanity/sdk'
2
8
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
9
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
4
10
 
@@ -190,21 +196,38 @@ export function useDocumentProjection<TData extends object>({
190
196
  const normalizedProjection = useMemo(() => projection.trim(), [projection])
191
197
 
192
198
  // Normalize options: resolve resourceName to resource and strip resourceName
193
- const normalizedDocHandle = useNormalizedResourceOptions(docHandle)
199
+ const {documentId, documentType, resource, perspective} = useNormalizedResourceOptions(docHandle)
194
200
 
195
- // Memoize stateSource based on normalized projection and docHandle properties
196
- // This prevents creating a new StateSource on every render when projection content is the same
201
+ // Resource normalization resolves aliases and context, but preserves property order.
202
+ // Serialize only handle identity fields in a fixed order so equivalent handles
203
+ // retain their subscription. Reading and suspending share the parsed options.
204
+ const normalizedDocHandle = {
205
+ documentId,
206
+ documentType,
207
+ resource:
208
+ resource &&
209
+ (isDatasetResource(resource)
210
+ ? {projectId: resource.projectId, dataset: resource.dataset}
211
+ : isMediaLibraryResource(resource)
212
+ ? {mediaLibraryId: resource.mediaLibraryId}
213
+ : {canvasId: resource.canvasId}),
214
+ perspective,
215
+ }
216
+ const docHandleKey = JSON.stringify(normalizedDocHandle)
217
+ const projectionOptions = useMemo(
218
+ () => ({
219
+ ...(JSON.parse(docHandleKey) as typeof normalizedDocHandle),
220
+ projection: normalizedProjection,
221
+ }),
222
+ [docHandleKey, normalizedProjection],
223
+ )
197
224
  const stateSource = useMemo(
198
- () =>
199
- getProjectionState<TData>(instance, {
200
- ...normalizedDocHandle,
201
- projection: normalizedProjection,
202
- }),
203
- [instance, normalizedDocHandle, normalizedProjection],
225
+ () => getProjectionState<TData>(instance, projectionOptions),
226
+ [instance, projectionOptions],
204
227
  )
205
228
 
206
229
  if (stateSource.getCurrent()?.data === null) {
207
- throw resolveProjection(instance, {...normalizedDocHandle, projection: normalizedProjection})
230
+ throw resolveProjection(instance, projectionOptions)
208
231
  }
209
232
 
210
233
  // Create subscribe function for useSyncExternalStore