@sanity/sdk-react 3.9.1 → 3.9.2-next.20261014121948
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/dist/_exports/dashboard-internal.js +1 -2
- package/dist/_exports/dashboard.js +2 -10
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/hooks/projection/useDocumentProjection.test.tsx +143 -11
- package/src/hooks/projection/useDocumentProjection.ts +34 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.2-next.20261014121948",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -45,8 +45,8 @@
|
|
|
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.
|
|
49
|
-
"@sanity/types": "^6.
|
|
48
|
+
"@sanity/sdk": "3.9.2-next.20261014121948",
|
|
49
|
+
"@sanity/types": "^6.19.0",
|
|
50
50
|
"react-compiler-runtime": "^1.0.0",
|
|
51
51
|
"react-error-boundary": "^6.1.6",
|
|
52
52
|
"rxjs": "^7.8.2"
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@repo/tsconfig": "0.0.1",
|
|
60
60
|
"@sanity/browserslist-config": "^1.0.5",
|
|
61
61
|
"@sanity/comlink": "^4.0.3",
|
|
62
|
-
"@sanity/pkg-utils": "^13.0.
|
|
62
|
+
"@sanity/pkg-utils": "^13.0.11",
|
|
63
63
|
"@testing-library/jest-dom": "^7.0.1",
|
|
64
64
|
"@testing-library/react": "^16.3.3",
|
|
65
65
|
"@types/node": "^24.19.1",
|
|
@@ -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.
|
|
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()
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
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
|
|
199
|
+
const {documentId, documentType, resource, perspective} = useNormalizedResourceOptions(docHandle)
|
|
194
200
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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
|
-
|
|
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,
|
|
230
|
+
throw resolveProjection(instance, projectionOptions)
|
|
208
231
|
}
|
|
209
232
|
|
|
210
233
|
// Create subscribe function for useSyncExternalStore
|