@sanity/sdk-react 2.1.0 → 2.1.2

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": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "private": false,
5
5
  "description": "Sanity SDK React toolkit for Content OS",
6
6
  "keywords": [
@@ -48,10 +48,10 @@
48
48
  "@types/lodash-es": "^4.17.12",
49
49
  "groq": "3.88.1-typegen-experimental.0",
50
50
  "lodash-es": "^4.17.21",
51
- "react-compiler-runtime": "19.1.0-rc.1",
51
+ "react-compiler-runtime": "19.1.0-rc.2",
52
52
  "react-error-boundary": "^5.0.0",
53
53
  "rxjs": "^7.8.2",
54
- "@sanity/sdk": "2.1.0"
54
+ "@sanity/sdk": "2.1.2"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sanity/browserslist-config": "^1.0.5",
@@ -53,6 +53,7 @@ export {
53
53
  type PaginatedDocumentsResponse,
54
54
  usePaginatedDocuments,
55
55
  } from '../hooks/paginatedDocuments/usePaginatedDocuments'
56
+ export {usePresence} from '../hooks/presence/usePresence'
56
57
  export {
57
58
  useDocumentPreview,
58
59
  type useDocumentPreviewOptions,
@@ -68,6 +69,7 @@ export {type ProjectWithoutMembers, useProjects} from '../hooks/projects/useProj
68
69
  export {useQuery} from '../hooks/query/useQuery'
69
70
  export {useActiveReleases} from '../hooks/releases/useActiveReleases'
70
71
  export {usePerspective} from '../hooks/releases/usePerspective'
72
+ export {type UserResult, useUser} from '../hooks/users/useUser'
71
73
  export {type UsersResult, useUsers} from '../hooks/users/useUsers'
72
74
  export {REACT_SDK_VERSION} from '../version'
73
75
  export {type DatasetsResponse, type SanityProjectMember} from '@sanity/client'
@@ -170,6 +170,7 @@ describe('SanityApp', () => {
170
170
 
171
171
  it('redirects to core if not inside iframe and not local url', async () => {
172
172
  const originalLocation = window.location
173
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
173
174
 
174
175
  const mockLocation = {
175
176
  replace: vi.fn(),
@@ -203,6 +204,7 @@ describe('SanityApp', () => {
203
204
  value: originalLocation,
204
205
  writable: true,
205
206
  })
207
+ consoleWarnSpy.mockRestore()
206
208
  })
207
209
 
208
210
  it('does not redirect to core if not inside iframe and local url', async () => {
@@ -241,4 +243,44 @@ describe('SanityApp', () => {
241
243
  writable: true,
242
244
  })
243
245
  })
246
+
247
+ it('does not redirect to core if studioMode is enabled', async () => {
248
+ const originalLocation = window.location
249
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
250
+
251
+ const mockLocation = {
252
+ replace: vi.fn(),
253
+ href: 'http://sanity-test.app',
254
+ }
255
+
256
+ const mockSanityConfig: SanityConfig = {
257
+ projectId: 'test-project',
258
+ dataset: 'test-dataset',
259
+ studioMode: {enabled: true},
260
+ }
261
+
262
+ Object.defineProperty(window, 'location', {
263
+ value: mockLocation,
264
+ writable: true,
265
+ })
266
+
267
+ render(
268
+ <SanityApp config={[mockSanityConfig]} fallback={<div>Fallback</div>}>
269
+ <div>Test Child</div>
270
+ </SanityApp>,
271
+ )
272
+
273
+ // Wait for 1 second
274
+ await new Promise((resolve) => setTimeout(resolve, 1010))
275
+
276
+ // Add assertions based on your iframe-specific behavior
277
+ expect(mockLocation.replace).not.toHaveBeenCalled()
278
+
279
+ // Clean up the mock
280
+ Object.defineProperty(window, 'location', {
281
+ value: originalLocation,
282
+ writable: true,
283
+ })
284
+ consoleWarnSpy.mockRestore()
285
+ })
244
286
  })
@@ -86,8 +86,9 @@ export function SanityApp({
86
86
  }: SanityAppProps): ReactElement {
87
87
  useEffect(() => {
88
88
  let timeout: NodeJS.Timeout | undefined
89
+ const primaryConfig = Array.isArray(config) ? config[0] : config
89
90
 
90
- if (!isInIframe() && !isLocalUrl(window)) {
91
+ if (!isInIframe() && !isLocalUrl(window) && !primaryConfig?.studioMode?.enabled) {
91
92
  // If the app is not running in an iframe and is not a local url, redirect to core.
92
93
  timeout = setTimeout(() => {
93
94
  // eslint-disable-next-line no-console
@@ -96,7 +97,7 @@ export function SanityApp({
96
97
  }, 1000)
97
98
  }
98
99
  return () => clearTimeout(timeout)
99
- }, [])
100
+ }, [config])
100
101
 
101
102
  return (
102
103
  <SDKProvider {...props} fallback={fallback} config={config}>
@@ -1,5 +1,5 @@
1
1
  import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
- import {render, screen} from '@testing-library/react'
2
+ import {act, render, screen} from '@testing-library/react'
3
3
  import {StrictMode, use, useEffect} from 'react'
4
4
  import {describe, expect, it} from 'vitest'
5
5
 
@@ -36,7 +36,7 @@ describe('ResourceProvider', () => {
36
36
  expect(screen.getByTestId('test-child')).toBeInTheDocument()
37
37
  })
38
38
 
39
- it('shows fallback during loading', () => {
39
+ it('shows fallback during loading', async () => {
40
40
  const {promise, resolve} = promiseWithResolvers()
41
41
  function SuspendingChild(): React.ReactNode {
42
42
  throw promise
@@ -49,7 +49,9 @@ describe('ResourceProvider', () => {
49
49
  )
50
50
 
51
51
  expect(screen.getByTestId('fallback')).toBeInTheDocument()
52
- resolve()
52
+ act(() => {
53
+ resolve()
54
+ })
53
55
  })
54
56
 
55
57
  it('creates root instance when no parent context exists', async () => {
@@ -138,7 +140,7 @@ describe('ResourceProvider', () => {
138
140
  expect(instance?.isDisposed()).toBe(false)
139
141
  })
140
142
 
141
- it('uses default fallback when none provided', () => {
143
+ it('uses default fallback when none provided', async () => {
142
144
  const {promise, resolve} = promiseWithResolvers()
143
145
  function SuspendingChild(): React.ReactNode {
144
146
  throw promise
@@ -152,6 +154,8 @@ describe('ResourceProvider', () => {
152
154
  )
153
155
 
154
156
  expect(screen.getByText(/Warning: No fallback provided/)).toBeInTheDocument()
155
- resolve()
157
+ act(() => {
158
+ resolve()
159
+ })
156
160
  })
157
161
  })
@@ -172,6 +172,7 @@ describe('useManageFavorite', () => {
172
172
 
173
173
  it('should throw error during favorite/unfavorite actions', async () => {
174
174
  const errorMessage = 'Failed to update favorite status'
175
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
175
176
 
176
177
  mockFetch.mockImplementation(() => {
177
178
  throw new Error(errorMessage)
@@ -191,6 +192,7 @@ describe('useManageFavorite', () => {
191
192
  })
192
193
 
193
194
  expect(resolveFavoritesState).not.toHaveBeenCalled()
195
+ consoleErrorSpy.mockRestore()
194
196
  })
195
197
 
196
198
  it('should throw error when studio resource is missing projectId or dataset', () => {
@@ -75,10 +75,12 @@ describe('useNavigateToStudioDocument', () => {
75
75
  })
76
76
 
77
77
  it('does not send message when no workspace is found', () => {
78
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
78
79
  mockWorkspacesByProjectIdAndDataset = {}
79
80
  const {result} = renderHook(() => useNavigateToStudioDocument(mockDocumentHandle))
80
81
  result.current.navigateToStudioDocument()
81
82
  expect(mockSendMessage).not.toHaveBeenCalled()
83
+ consoleSpy.mockRestore()
82
84
  })
83
85
 
84
86
  it('warns when multiple workspaces are found', () => {
@@ -45,6 +45,7 @@ describe('useRecordDocumentHistoryEvent', () => {
45
45
  })
46
46
 
47
47
  it('should handle errors when sending messages', () => {
48
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
48
49
  mockSendMessage.mockImplementation(() => {
49
50
  throw new Error('Failed to send message')
50
51
  })
@@ -52,6 +53,7 @@ describe('useRecordDocumentHistoryEvent', () => {
52
53
  const {result} = renderHook(() => useRecordDocumentHistoryEvent(mockDocumentHandle))
53
54
 
54
55
  expect(() => result.current.recordEvent('viewed')).toThrow('Failed to send message')
56
+ consoleErrorSpy.mockRestore()
55
57
  })
56
58
 
57
59
  it('should throw error when resourceId is missing for non-studio resources', () => {
@@ -0,0 +1,80 @@
1
+ import {getPresence, type SanityUser, type UserPresence} from '@sanity/sdk'
2
+ import {NEVER} from 'rxjs'
3
+ import {describe, expect, it, vi} from 'vitest'
4
+
5
+ import {act, renderHook} from '../../../test/test-utils'
6
+ import {usePresence} from './usePresence'
7
+
8
+ vi.mock('@sanity/sdk', () => ({
9
+ getPresence: vi.fn(),
10
+ createSanityInstance: vi.fn(() => ({
11
+ createChild: vi.fn(),
12
+ isDisposed: vi.fn(() => false),
13
+ dispose: vi.fn(),
14
+ })),
15
+ }))
16
+
17
+ vi.mock('../context/useSanityInstance', () => ({
18
+ useSanityInstance: vi.fn(() => ({config: {projectId: 'test', dataset: 'test'}})),
19
+ }))
20
+
21
+ describe('usePresence', () => {
22
+ it('should return presence locations and update when the store changes', () => {
23
+ const initialLocations: UserPresence[] = [
24
+ {
25
+ user: {
26
+ sanityUserId: 'user1',
27
+ profile: undefined,
28
+ memberships: [],
29
+ } as unknown as SanityUser,
30
+ sessionId: 'session1',
31
+ state: 'online',
32
+ lastActiveAt: new Date().toISOString(),
33
+ },
34
+ ] as unknown as UserPresence[]
35
+ const updatedLocations: UserPresence[] = [
36
+ ...initialLocations,
37
+ {
38
+ user: {
39
+ sanityUserId: 'user2',
40
+ profile: undefined,
41
+ memberships: [],
42
+ } as unknown as SanityUser,
43
+ sessionId: 'session2',
44
+ state: 'online',
45
+ lastActiveAt: new Date().toISOString(),
46
+ },
47
+ ] as unknown as UserPresence[]
48
+
49
+ let onStoreChange: () => void = () => {}
50
+ const getCurrent = vi.fn().mockReturnValue(initialLocations)
51
+ const mockPresenceSource = {
52
+ // It's called once for the server snapshot, and once for the client
53
+ getCurrent,
54
+ subscribe: vi.fn((callback) => {
55
+ onStoreChange = callback
56
+ // Return an unsubscribe function
57
+ return () => {}
58
+ }),
59
+ observable: NEVER,
60
+ }
61
+ vi.mocked(getPresence).mockReturnValue(mockPresenceSource)
62
+
63
+ const {result, unmount} = renderHook(() => usePresence())
64
+
65
+ // Initial state should be correct
66
+ expect(result.current.locations).toEqual(initialLocations)
67
+ expect(getCurrent).toHaveBeenCalled()
68
+ expect(mockPresenceSource.subscribe).toHaveBeenCalledTimes(1)
69
+
70
+ // Update state
71
+ getCurrent.mockReturnValue(updatedLocations)
72
+ act(() => {
73
+ onStoreChange()
74
+ })
75
+
76
+ // The hook should have been updated
77
+ expect(result.current.locations).toEqual(updatedLocations)
78
+ unmount()
79
+ })
80
+ })
@@ -0,0 +1,23 @@
1
+ import {getPresence, type UserPresence} from '@sanity/sdk'
2
+ import {useCallback, useMemo, useSyncExternalStore} from 'react'
3
+
4
+ import {useSanityInstance} from '../context/useSanityInstance'
5
+
6
+ /**
7
+ * A hook for subscribing to presence information for the current project.
8
+ * @public
9
+ */
10
+ export function usePresence(): {
11
+ locations: UserPresence[]
12
+ } {
13
+ const sanityInstance = useSanityInstance()
14
+ const source = useMemo(() => getPresence(sanityInstance), [sanityInstance])
15
+ const subscribe = useCallback((callback: () => void) => source.subscribe(callback), [source])
16
+ const locations = useSyncExternalStore(
17
+ subscribe,
18
+ () => source.getCurrent(),
19
+ () => source.getCurrent(),
20
+ )
21
+
22
+ return {locations: locations || []}
23
+ }