@sanity/sdk-react 0.0.0-alpha.3 → 0.0.0-alpha.30

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.
Files changed (131) hide show
  1. package/README.md +6 -100
  2. package/dist/index.d.ts +2390 -2
  3. package/dist/index.js +1119 -2
  4. package/dist/index.js.map +1 -1
  5. package/package.json +35 -49
  6. package/src/_exports/index.ts +2 -10
  7. package/src/_exports/sdk-react.ts +73 -0
  8. package/src/components/SDKProvider.test.tsx +103 -0
  9. package/src/components/SDKProvider.tsx +52 -0
  10. package/src/components/SanityApp.test.tsx +244 -0
  11. package/src/components/SanityApp.tsx +106 -0
  12. package/src/components/auth/AuthBoundary.test.tsx +204 -29
  13. package/src/components/auth/AuthBoundary.tsx +96 -19
  14. package/src/components/auth/ConfigurationError.ts +22 -0
  15. package/src/components/auth/LoginCallback.test.tsx +22 -24
  16. package/src/components/auth/LoginCallback.tsx +6 -16
  17. package/src/components/auth/LoginError.test.tsx +11 -18
  18. package/src/components/auth/LoginError.tsx +43 -25
  19. package/src/components/utils.ts +22 -0
  20. package/src/context/ResourceProvider.test.tsx +157 -0
  21. package/src/context/ResourceProvider.tsx +111 -0
  22. package/src/context/SanityInstanceContext.ts +4 -0
  23. package/src/hooks/_synchronous-groq-js.mjs +4 -0
  24. package/src/hooks/auth/useAuthState.tsx +4 -5
  25. package/src/hooks/auth/useAuthToken.tsx +1 -1
  26. package/src/hooks/auth/useCurrentUser.tsx +28 -4
  27. package/src/hooks/auth/useDashboardOrganizationId.test.tsx +42 -0
  28. package/src/hooks/auth/useDashboardOrganizationId.tsx +30 -0
  29. package/src/hooks/auth/useHandleAuthCallback.test.tsx +16 -0
  30. package/src/hooks/auth/{useHandleCallback.tsx → useHandleAuthCallback.tsx} +7 -6
  31. package/src/hooks/auth/useLogOut.test.tsx +2 -2
  32. package/src/hooks/auth/useLogOut.tsx +1 -1
  33. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  34. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  35. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  36. package/src/hooks/client/useClient.ts +13 -33
  37. package/src/hooks/comlink/useFrameConnection.test.tsx +167 -0
  38. package/src/hooks/comlink/useFrameConnection.ts +107 -0
  39. package/src/hooks/comlink/useManageFavorite.test.ts +368 -0
  40. package/src/hooks/comlink/useManageFavorite.ts +210 -0
  41. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +85 -0
  42. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +115 -0
  43. package/src/hooks/comlink/useWindowConnection.test.ts +135 -0
  44. package/src/hooks/comlink/useWindowConnection.ts +123 -0
  45. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  46. package/src/hooks/context/useSanityInstance.ts +68 -11
  47. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +276 -0
  48. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +139 -0
  49. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.test.tsx +291 -0
  50. package/src/hooks/dashboard/useStudioWorkspacesByProjectIdDataset.ts +101 -0
  51. package/src/hooks/datasets/useDatasets.test.ts +80 -0
  52. package/src/hooks/datasets/useDatasets.ts +52 -0
  53. package/src/hooks/document/useApplyDocumentActions.test.ts +20 -0
  54. package/src/hooks/document/useApplyDocumentActions.ts +124 -0
  55. package/src/hooks/document/useDocument.test.ts +118 -0
  56. package/src/hooks/document/useDocument.ts +212 -0
  57. package/src/hooks/document/useDocumentEvent.test.ts +62 -0
  58. package/src/hooks/document/useDocumentEvent.ts +94 -0
  59. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  60. package/src/hooks/document/useDocumentPermissions.ts +131 -0
  61. package/src/hooks/document/useDocumentSyncStatus.test.ts +23 -0
  62. package/src/hooks/document/useDocumentSyncStatus.ts +61 -0
  63. package/src/hooks/document/useEditDocument.test.ts +196 -0
  64. package/src/hooks/document/useEditDocument.ts +314 -0
  65. package/src/hooks/documents/useDocuments.test.tsx +179 -0
  66. package/src/hooks/documents/useDocuments.ts +300 -0
  67. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  68. package/src/hooks/helpers/createCallbackHook.tsx +1 -1
  69. package/src/hooks/helpers/createStateSourceHook.test.tsx +67 -1
  70. package/src/hooks/helpers/createStateSourceHook.tsx +27 -11
  71. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +284 -0
  72. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +353 -0
  73. package/src/hooks/preview/usePreview.test.tsx +85 -17
  74. package/src/hooks/preview/usePreview.tsx +81 -22
  75. package/src/hooks/projection/useProjection.test.tsx +283 -0
  76. package/src/hooks/projection/useProjection.ts +232 -0
  77. package/src/hooks/projects/useProject.test.ts +80 -0
  78. package/src/hooks/projects/useProject.ts +51 -0
  79. package/src/hooks/projects/useProjects.test.ts +77 -0
  80. package/src/hooks/projects/useProjects.ts +45 -0
  81. package/src/hooks/query/useQuery.test.tsx +188 -0
  82. package/src/hooks/query/useQuery.ts +193 -0
  83. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  84. package/src/hooks/releases/useActiveReleases.ts +39 -0
  85. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  86. package/src/hooks/releases/usePerspective.ts +49 -0
  87. package/src/hooks/users/useUsers.test.tsx +330 -0
  88. package/src/hooks/users/useUsers.ts +120 -0
  89. package/src/utils/getEnv.ts +21 -0
  90. package/src/version.ts +8 -0
  91. package/src/vite-env.d.ts +10 -0
  92. package/dist/_chunks-es/useLogOut.js +0 -44
  93. package/dist/_chunks-es/useLogOut.js.map +0 -1
  94. package/dist/assets/bundle-CcAyERuZ.css +0 -11
  95. package/dist/components.d.ts +0 -259
  96. package/dist/components.js +0 -301
  97. package/dist/components.js.map +0 -1
  98. package/dist/hooks.d.ts +0 -186
  99. package/dist/hooks.js +0 -81
  100. package/dist/hooks.js.map +0 -1
  101. package/src/_exports/components.ts +0 -13
  102. package/src/_exports/hooks.ts +0 -9
  103. package/src/components/DocumentGridLayout/DocumentGridLayout.stories.tsx +0 -113
  104. package/src/components/DocumentGridLayout/DocumentGridLayout.test.tsx +0 -42
  105. package/src/components/DocumentGridLayout/DocumentGridLayout.tsx +0 -21
  106. package/src/components/DocumentListLayout/DocumentListLayout.stories.tsx +0 -105
  107. package/src/components/DocumentListLayout/DocumentListLayout.test.tsx +0 -42
  108. package/src/components/DocumentListLayout/DocumentListLayout.tsx +0 -12
  109. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.md +0 -49
  110. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.stories.tsx +0 -39
  111. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.test.tsx +0 -30
  112. package/src/components/DocumentPreviewLayout/DocumentPreviewLayout.tsx +0 -171
  113. package/src/components/Login/LoginLinks.test.tsx +0 -100
  114. package/src/components/Login/LoginLinks.tsx +0 -73
  115. package/src/components/auth/Login.test.tsx +0 -41
  116. package/src/components/auth/Login.tsx +0 -45
  117. package/src/components/auth/LoginFooter.test.tsx +0 -29
  118. package/src/components/auth/LoginFooter.tsx +0 -65
  119. package/src/components/auth/LoginLayout.test.tsx +0 -33
  120. package/src/components/auth/LoginLayout.tsx +0 -81
  121. package/src/components/context/SanityProvider.test.tsx +0 -25
  122. package/src/components/context/SanityProvider.tsx +0 -42
  123. package/src/css/css.config.js +0 -220
  124. package/src/css/paramour.css +0 -2347
  125. package/src/css/styles.css +0 -11
  126. package/src/hooks/auth/useHandleCallback.test.tsx +0 -16
  127. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  128. package/src/hooks/auth/useLoginUrls.tsx +0 -51
  129. package/src/hooks/client/useClient.test.tsx +0 -130
  130. package/src/hooks/documentCollection/useDocuments.test.ts +0 -130
  131. package/src/hooks/documentCollection/useDocuments.ts +0 -87
@@ -0,0 +1,193 @@
1
+ import {
2
+ getQueryKey,
3
+ getQueryState,
4
+ parseQueryKey,
5
+ type QueryOptions,
6
+ resolveQuery,
7
+ } from '@sanity/sdk'
8
+ import {type SanityQueryResult} from 'groq'
9
+ import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
10
+
11
+ import {useSanityInstance} from '../context/useSanityInstance'
12
+
13
+ // Overload 1: Inferred Type (using Typegen)
14
+ /**
15
+ * @beta
16
+ * Executes a GROQ query, inferring the result type from the query string and options.
17
+ * Leverages Sanity Typegen if configured for enhanced type safety.
18
+ *
19
+ * @param options - Configuration for the query, including `query`, optional `params`, `projectId`, `dataset`, etc.
20
+ * @returns An object containing `data` (typed based on the query) and `isPending` (for transitions).
21
+ *
22
+ * @example Basic usage (Inferred Type)
23
+ * ```tsx
24
+ * import {useQuery} from '@sanity/sdk-react'
25
+ * import {defineQuery} from 'groq'
26
+ *
27
+ * const myQuery = defineQuery(`*[_type == "movie"]{_id, title}`)
28
+ *
29
+ * function MovieList() {
30
+ * // Typegen infers the return type for data
31
+ * const {data} = useQuery({ query: myQuery })
32
+ *
33
+ * return (
34
+ * <div>
35
+ * <h2>Movies</h2>
36
+ * <ul>
37
+ * {data.map(movie => <li key={movie._id}>{movie.title}</li>)}
38
+ * </ul>
39
+ * </div>
40
+ * )
41
+ * }
42
+ * // Suspense boundary should wrap <MovieList /> for initial load
43
+ * ```
44
+ *
45
+ * @example Using parameters (Inferred Type)
46
+ * ```tsx
47
+ * import {useQuery} from '@sanity/sdk-react'
48
+ * import {defineQuery} from 'groq'
49
+ *
50
+ * const myQuery = defineQuery(`*[_type == "movie" && _id == $id][0]`)
51
+ *
52
+ * function MovieDetails({movieId}: {movieId: string}) {
53
+ * // Typegen infers the return type based on query and params
54
+ * const {data, isPending} = useQuery({
55
+ * query: myQuery,
56
+ * params: { id: movieId }
57
+ * })
58
+ *
59
+ * return (
60
+ * // utilize `isPending` to signal to users that new data is coming in
61
+ * // (e.g. the `movieId` changed and we're loading in the new one)
62
+ * <div style={{ opacity: isPending ? 0.5 : 1 }}>
63
+ * {data ? <h1>{data.title}</h1> : <p>Movie not found</p>}
64
+ * </div>
65
+ * )
66
+ * }
67
+ * ```
68
+ */
69
+ export function useQuery<
70
+ TQuery extends string = string,
71
+ TDataset extends string = string,
72
+ TProjectId extends string = string,
73
+ >(
74
+ options: QueryOptions<TQuery, TDataset, TProjectId>,
75
+ ): {
76
+ /** The query result, typed based on the GROQ query string */
77
+ data: SanityQueryResult<TQuery, TDataset, TProjectId>
78
+ /** True if a query transition is in progress */
79
+ isPending: boolean
80
+ }
81
+
82
+ // Overload 2: Explicit Type Provided
83
+ /**
84
+ * @beta
85
+ * Executes a GROQ query with an explicitly provided result type `TData`.
86
+ *
87
+ * @param options - Configuration for the query, including `query`, optional `params`, `projectId`, `dataset`, etc.
88
+ * @returns An object containing `data` (cast to `TData`) and `isPending` (indicates whether a query resolution is pending; note that Suspense handles initial loading states). *
89
+ * @example Manually typed query result
90
+ * ```tsx
91
+ * import {useQuery} from '@sanity/sdk-react'
92
+ *
93
+ * interface CustomMovieTitle {
94
+ * movieTitle?: string
95
+ * }
96
+ *
97
+ * function FirstMovieTitle() {
98
+ * // Provide the explicit type TData
99
+ * const {data, isPending} = useQuery<CustomMovieTitle>({
100
+ * query: '*[_type == "movie"][0]{ "movieTitle": title }'
101
+ * })
102
+ *
103
+ * return (
104
+ * <h1 style={{ opacity: isPending ? 0.5 : 1 }}>
105
+ * {data?.movieTitle ?? 'No title found'}
106
+ * </h1>
107
+ * )
108
+ * }
109
+ * ```
110
+ */
111
+ export function useQuery<TData>(options: QueryOptions): {
112
+ /** The query result, cast to the provided type TData */
113
+ data: TData
114
+ /** True if another query is resolving in the background (suspense handles the initial loading state) */
115
+ isPending: boolean
116
+ }
117
+
118
+ /**
119
+ * @beta
120
+ * Fetches data and subscribes to real-time updates using a GROQ query.
121
+ *
122
+ * @remarks
123
+ * This hook provides a convenient way to fetch data from your Sanity dataset and
124
+ * automatically receive updates in real-time when the queried data changes.
125
+ *
126
+ * Features:
127
+ * - Executes any valid GROQ query.
128
+ * - Subscribes to changes, providing real-time updates.
129
+ * - Integrates with React Suspense for handling initial loading states.
130
+ * - Uses React Transitions for managing loading states during query/parameter changes (indicated by `isPending`).
131
+ * - Supports type inference based on the GROQ query when using Sanity Typegen.
132
+ * - Allows specifying an explicit return type `TData` for the query result.
133
+ *
134
+ * @category GROQ
135
+ */
136
+ export function useQuery(options: QueryOptions): {data: unknown; isPending: boolean} {
137
+ // Implementation returns unknown, overloads define specifics
138
+ const instance = useSanityInstance(options)
139
+
140
+ // Use React's useTransition to avoid UI jank when queries change
141
+ const [isPending, startTransition] = useTransition()
142
+
143
+ // Get the unique key for this query and its options
144
+ const queryKey = getQueryKey(options)
145
+ // Use a deferred state to avoid immediate re-renders when the query changes
146
+ const [deferredQueryKey, setDeferredQueryKey] = useState(queryKey)
147
+ // Parse the deferred query key back into a query and options
148
+ const deferred = useMemo(() => parseQueryKey(deferredQueryKey), [deferredQueryKey])
149
+
150
+ // Create an AbortController to cancel in-flight requests when needed
151
+ const ref = useRef<AbortController>(new AbortController())
152
+
153
+ // When the query or options change, start a transition to update the query
154
+ useEffect(() => {
155
+ if (queryKey === deferredQueryKey) return
156
+
157
+ startTransition(() => {
158
+ // Abort any in-flight requests for the previous query
159
+ if (ref && !ref.current.signal.aborted) {
160
+ ref.current.abort()
161
+ ref.current = new AbortController()
162
+ }
163
+
164
+ setDeferredQueryKey(queryKey)
165
+ })
166
+ }, [deferredQueryKey, queryKey])
167
+
168
+ // Get the state source for this query from the query store
169
+ const {getCurrent, subscribe} = useMemo(
170
+ () => getQueryState(instance, deferred),
171
+ [instance, deferred],
172
+ )
173
+
174
+ // If data isn't available yet, suspend rendering
175
+ if (getCurrent() === undefined) {
176
+ // Normally, reading from a mutable ref during render can be risky in concurrent mode.
177
+ // However, it is safe here because:
178
+ // 1. React guarantees that while the component is suspended (via throwing a promise),
179
+ // no effects or state updates occur during that render pass.
180
+ // 2. We immediately capture the current abort signal in a local variable (currentSignal).
181
+ // 3. Even if a background render updates ref.current (for example, due to a query change),
182
+ // the captured signal remains unchanged for this suspended render.
183
+ // Thus, the promise thrown here uses a stable abort signal, ensuring correct behavior.
184
+ const currentSignal = ref.current.signal
185
+ // eslint-disable-next-line react-compiler/react-compiler
186
+ throw resolveQuery(instance, {...deferred, signal: currentSignal})
187
+ }
188
+
189
+ // Subscribe to updates and get the current data
190
+ // useSyncExternalStore ensures the component re-renders when the data changes
191
+ const data = useSyncExternalStore(subscribe, getCurrent) as SanityQueryResult
192
+ return useMemo(() => ({data, isPending}), [data, isPending])
193
+ }
@@ -0,0 +1,84 @@
1
+ import {getActiveReleasesState, type ReleaseDocument, type SanityInstance} from '@sanity/sdk'
2
+ import {renderHook} from '@testing-library/react'
3
+ import {BehaviorSubject} from 'rxjs'
4
+ import {describe, expect, it, vi} from 'vitest'
5
+
6
+ import {useSanityInstance} from '../context/useSanityInstance'
7
+ import {useActiveReleases} from './useActiveReleases'
8
+
9
+ // Mock the useSanityInstance hook
10
+ vi.mock('../context/useSanityInstance', () => ({
11
+ useSanityInstance: vi.fn(),
12
+ }))
13
+
14
+ // Mock the getActiveReleasesState function
15
+ vi.mock('@sanity/sdk', async () => {
16
+ const actual = await vi.importActual('@sanity/sdk')
17
+ return {
18
+ ...actual,
19
+ getActiveReleasesState: vi.fn(),
20
+ }
21
+ })
22
+
23
+ describe('useActiveReleases', () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks()
26
+ })
27
+
28
+ it('should suspend when initial state is undefined', () => {
29
+ const mockInstance = {} as SanityInstance
30
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
31
+
32
+ const mockSubject = new BehaviorSubject<ReleaseDocument[] | undefined>(undefined)
33
+ const mockStateSource = {
34
+ subscribe: vi.fn((callback) => {
35
+ const subscription = mockSubject.subscribe(callback)
36
+ return () => subscription.unsubscribe()
37
+ }),
38
+ getCurrent: vi.fn(() => undefined),
39
+ observable: mockSubject,
40
+ }
41
+
42
+ vi.mocked(getActiveReleasesState).mockReturnValue(mockStateSource)
43
+
44
+ const {result} = renderHook(() => {
45
+ try {
46
+ return useActiveReleases()
47
+ } catch (e) {
48
+ return e
49
+ }
50
+ })
51
+
52
+ // Verify that the hook threw a promise (suspended)
53
+ expect(result.current).toBeInstanceOf(Promise)
54
+ expect(mockStateSource.getCurrent).toHaveBeenCalled()
55
+ })
56
+
57
+ it('should resolve with releases when data is available', () => {
58
+ const mockInstance = {} as SanityInstance
59
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
60
+
61
+ const mockReleases: ReleaseDocument[] = [
62
+ {_id: 'release1', _type: 'release'} as ReleaseDocument,
63
+ {_id: 'release2', _type: 'release'} as ReleaseDocument,
64
+ ]
65
+
66
+ const mockSubject = new BehaviorSubject<ReleaseDocument[]>(mockReleases)
67
+ const mockStateSource = {
68
+ subscribe: vi.fn((callback) => {
69
+ const subscription = mockSubject.subscribe(callback)
70
+ return () => subscription.unsubscribe()
71
+ }),
72
+ getCurrent: vi.fn(() => mockReleases),
73
+ observable: mockSubject,
74
+ }
75
+
76
+ vi.mocked(getActiveReleasesState).mockReturnValue(mockStateSource)
77
+
78
+ const {result} = renderHook(() => useActiveReleases())
79
+
80
+ // Verify that the hook returned the releases without suspending
81
+ expect(result.current).toEqual(mockReleases)
82
+ expect(mockStateSource.getCurrent).toHaveBeenCalled()
83
+ })
84
+ })
@@ -0,0 +1,39 @@
1
+ import {
2
+ getActiveReleasesState,
3
+ type ReleaseDocument,
4
+ type SanityInstance,
5
+ type StateSource,
6
+ } from '@sanity/sdk'
7
+ import {filter, firstValueFrom} from 'rxjs'
8
+
9
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
10
+
11
+ /**
12
+ * @public
13
+ */
14
+ type UseActiveReleases = {
15
+ (): ReleaseDocument[]
16
+ }
17
+
18
+ /**
19
+ * @public
20
+
21
+ * Returns the active releases for the current project,
22
+ * represented as a list of release documents.
23
+ *
24
+ * @returns The active releases for the current project.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * import {useActiveReleases} from '@sanity/sdk-react'
29
+ *
30
+ * const activeReleases = useActiveReleases()
31
+ * ```
32
+ */
33
+ export const useActiveReleases: UseActiveReleases = createStateSourceHook({
34
+ getState: getActiveReleasesState as (instance: SanityInstance) => StateSource<ReleaseDocument[]>,
35
+ shouldSuspend: (instance: SanityInstance) =>
36
+ getActiveReleasesState(instance).getCurrent() === undefined,
37
+ suspender: (instance: SanityInstance) =>
38
+ firstValueFrom(getActiveReleasesState(instance).observable.pipe(filter(Boolean))),
39
+ })
@@ -0,0 +1,120 @@
1
+ import {type ClientPerspective} from '@sanity/client'
2
+ import {
3
+ getActiveReleasesState,
4
+ getPerspectiveState,
5
+ type PerspectiveHandle,
6
+ type ReleaseDocument,
7
+ type SanityInstance,
8
+ } from '@sanity/sdk'
9
+ import {renderHook} from '@testing-library/react'
10
+ import {BehaviorSubject} from 'rxjs'
11
+ import {describe, expect, it, vi} from 'vitest'
12
+
13
+ import {useSanityInstance} from '../context/useSanityInstance'
14
+ import {usePerspective} from './usePerspective'
15
+
16
+ // Mock the useSanityInstance hook
17
+ vi.mock('../context/useSanityInstance', () => ({
18
+ useSanityInstance: vi.fn(),
19
+ }))
20
+
21
+ // Mock the SDK functions
22
+ vi.mock('@sanity/sdk', async () => {
23
+ const actual = await vi.importActual('@sanity/sdk')
24
+ return {
25
+ ...actual,
26
+ getPerspectiveState: vi.fn(),
27
+ // getPerspectiveState uses getActiveReleasesState
28
+ // to determine if it should suspend
29
+ getActiveReleasesState: vi.fn(),
30
+ }
31
+ })
32
+
33
+ describe('usePerspective', () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks()
36
+ })
37
+
38
+ it('should suspend when initial state is undefined', () => {
39
+ const mockInstance = {} as SanityInstance
40
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
41
+
42
+ const perspectiveHandle: PerspectiveHandle = {
43
+ perspective: 'published',
44
+ }
45
+
46
+ const mockSubject = new BehaviorSubject<ClientPerspective | undefined>(undefined)
47
+ const mockStateSource = {
48
+ subscribe: vi.fn((callback) => {
49
+ const subscription = mockSubject.subscribe(callback)
50
+ return () => subscription.unsubscribe()
51
+ }),
52
+ getCurrent: vi.fn(() => undefined),
53
+ observable: mockSubject,
54
+ }
55
+
56
+ // Mock the active releases observable for the suspender
57
+ const mockReleaseDoc: ReleaseDocument = {
58
+ _id: 'release1',
59
+ _type: 'release',
60
+ _createdAt: '2021-01-01T00:00:00Z',
61
+ _updatedAt: '2021-01-01T00:00:00Z',
62
+ _rev: 'rev1',
63
+ name: 'Test Release',
64
+ metadata: {
65
+ title: 'Test Release',
66
+ releaseType: 'asap',
67
+ },
68
+ }
69
+ const mockReleasesSubject = new BehaviorSubject([mockReleaseDoc])
70
+ const mockReleasesStateSource = {
71
+ subscribe: vi.fn(),
72
+ getCurrent: vi.fn(),
73
+ observable: mockReleasesSubject,
74
+ }
75
+
76
+ vi.mocked(getPerspectiveState).mockReturnValue(mockStateSource)
77
+ vi.mocked(getActiveReleasesState).mockReturnValue(mockReleasesStateSource)
78
+
79
+ const {result} = renderHook(() => {
80
+ try {
81
+ return usePerspective(perspectiveHandle)
82
+ } catch (e) {
83
+ return e
84
+ }
85
+ })
86
+
87
+ // Verify that the hook threw a promise (suspended)
88
+ expect(result.current).toBeInstanceOf(Promise)
89
+ expect(mockStateSource.getCurrent).toHaveBeenCalled()
90
+ })
91
+
92
+ it('should resolve with perspective when data is available', () => {
93
+ const mockInstance = {} as SanityInstance
94
+ vi.mocked(useSanityInstance).mockReturnValue(mockInstance)
95
+
96
+ const perspectiveHandle: PerspectiveHandle = {
97
+ perspective: 'published',
98
+ }
99
+
100
+ const mockPerspective: ClientPerspective = 'published'
101
+ const mockSubject = new BehaviorSubject<ClientPerspective>(mockPerspective)
102
+ const mockStateSource = {
103
+ subscribe: vi.fn((callback) => {
104
+ const subscription = mockSubject.subscribe(callback)
105
+ return () => subscription.unsubscribe()
106
+ }),
107
+ getCurrent: vi.fn(() => mockPerspective),
108
+ observable: mockSubject,
109
+ }
110
+
111
+ vi.mocked(getPerspectiveState).mockReturnValue(mockStateSource)
112
+
113
+ const {result} = renderHook(() => usePerspective(perspectiveHandle))
114
+
115
+ // Verify that the hook returned the perspective without suspending
116
+ expect(result.current).toEqual(mockPerspective)
117
+ expect(mockStateSource.getCurrent).toHaveBeenCalled()
118
+ expect(getPerspectiveState).toHaveBeenCalledWith(mockInstance, perspectiveHandle)
119
+ })
120
+ })
@@ -0,0 +1,49 @@
1
+ import {
2
+ getActiveReleasesState,
3
+ getPerspectiveState,
4
+ type PerspectiveHandle,
5
+ type SanityInstance,
6
+ type StateSource,
7
+ } from '@sanity/sdk'
8
+ import {filter, firstValueFrom} from 'rxjs'
9
+
10
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
11
+
12
+ /**
13
+ * @public
14
+ */
15
+ type UsePerspective = {
16
+ (perspectiveHandle: PerspectiveHandle): string | string[]
17
+ }
18
+
19
+ /**
20
+ * @public
21
+ *
22
+ * Returns a single or stack of perspectives for the given perspective handle,
23
+ * which can then be used to correctly query the documents
24
+ * via the `perspective` parameter in the client.
25
+ *
26
+ * @param perspectiveHandle - The perspective handle to get the perspective for.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import {usePerspective, useQuery} from '@sanity/sdk-react'
31
+
32
+ * const perspective = usePerspective({perspective: 'rxg1346', projectId: 'abc123', dataset: 'production'})
33
+ * const {data} = useQuery<Movie[]>('*[_type == "movie"]', {
34
+ * perspective: perspective,
35
+ * })
36
+ * ```
37
+ *
38
+ * @returns The perspective for the given perspective handle.
39
+ */
40
+ export const usePerspective: UsePerspective = createStateSourceHook({
41
+ getState: getPerspectiveState as (
42
+ instance: SanityInstance,
43
+ perspectiveHandle?: PerspectiveHandle,
44
+ ) => StateSource<string | string[]>,
45
+ shouldSuspend: (instance: SanityInstance, options?: PerspectiveHandle): boolean =>
46
+ getPerspectiveState(instance, options).getCurrent() === undefined,
47
+ suspender: (instance: SanityInstance, _options?: PerspectiveHandle) =>
48
+ firstValueFrom(getActiveReleasesState(instance).observable.pipe(filter(Boolean))),
49
+ })