@sanity/sdk-react 0.0.0-rc.6 → 0.0.0-rc.8

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 (40) hide show
  1. package/README.md +5 -57
  2. package/dist/index.d.ts +998 -437
  3. package/dist/index.js +324 -258
  4. package/dist/index.js.map +1 -1
  5. package/package.json +16 -15
  6. package/src/_exports/sdk-react.ts +4 -1
  7. package/src/components/SDKProvider.tsx +6 -1
  8. package/src/components/SanityApp.test.tsx +29 -47
  9. package/src/components/SanityApp.tsx +12 -11
  10. package/src/components/auth/AuthBoundary.test.tsx +177 -7
  11. package/src/components/auth/AuthBoundary.tsx +31 -1
  12. package/src/components/auth/ConfigurationError.ts +22 -0
  13. package/src/components/auth/LoginError.tsx +9 -3
  14. package/src/hooks/auth/useVerifyOrgProjects.test.tsx +136 -0
  15. package/src/hooks/auth/useVerifyOrgProjects.tsx +48 -0
  16. package/src/hooks/client/useClient.ts +3 -3
  17. package/src/hooks/comlink/useManageFavorite.test.ts +276 -27
  18. package/src/hooks/comlink/useManageFavorite.ts +102 -51
  19. package/src/hooks/comlink/useWindowConnection.ts +3 -2
  20. package/src/hooks/document/useApplyDocumentActions.ts +105 -31
  21. package/src/hooks/document/useDocument.test.ts +41 -4
  22. package/src/hooks/document/useDocument.ts +198 -114
  23. package/src/hooks/document/useDocumentEvent.test.ts +5 -5
  24. package/src/hooks/document/useDocumentEvent.ts +67 -23
  25. package/src/hooks/document/useDocumentPermissions.ts +47 -8
  26. package/src/hooks/document/useDocumentSyncStatus.test.ts +12 -5
  27. package/src/hooks/document/useDocumentSyncStatus.ts +41 -14
  28. package/src/hooks/document/useEditDocument.test.ts +24 -6
  29. package/src/hooks/document/useEditDocument.ts +238 -133
  30. package/src/hooks/documents/useDocuments.test.tsx +1 -1
  31. package/src/hooks/documents/useDocuments.ts +153 -44
  32. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +1 -1
  33. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +120 -47
  34. package/src/hooks/projection/useProjection.ts +134 -46
  35. package/src/hooks/query/useQuery.test.tsx +4 -4
  36. package/src/hooks/query/useQuery.ts +115 -43
  37. package/src/hooks/releases/useActiveReleases.test.tsx +84 -0
  38. package/src/hooks/releases/useActiveReleases.ts +39 -0
  39. package/src/hooks/releases/usePerspective.test.tsx +120 -0
  40. package/src/hooks/releases/usePerspective.ts +49 -0
@@ -4,6 +4,7 @@ import {
4
4
  resolveProjection,
5
5
  type ValidProjection,
6
6
  } from '@sanity/sdk'
7
+ import {type SanityProjectionResult} from 'groq'
7
8
  import {useCallback, useSyncExternalStore} from 'react'
8
9
  import {distinctUntilChanged, EMPTY, Observable, startWith, switchMap} from 'rxjs'
9
10
 
@@ -13,79 +14,166 @@ import {useSanityInstance} from '../context/useSanityInstance'
13
14
  * @public
14
15
  * @category Types
15
16
  */
16
- export interface UseProjectionOptions extends DocumentHandle {
17
+ export interface UseProjectionOptions<
18
+ TProjection extends ValidProjection = ValidProjection,
19
+ TDocumentType extends string = string,
20
+ TDataset extends string = string,
21
+ TProjectId extends string = string,
22
+ > extends DocumentHandle<TDocumentType, TDataset, TProjectId> {
23
+ /** The GROQ projection string */
24
+ projection: TProjection
25
+ /** Optional parameters for the projection query */
26
+ params?: Record<string, unknown>
27
+ /** Optional ref to track viewport intersection for lazy loading */
17
28
  ref?: React.RefObject<unknown>
18
- projection: ValidProjection
19
29
  }
20
30
 
21
31
  /**
22
32
  * @public
23
33
  * @category Types
24
34
  */
25
- export interface UseProjectionResults<TData extends object> {
35
+ export interface UseProjectionResults<TData> {
36
+ /** The projected data */
26
37
  data: TData
38
+ /** True if the projection is currently being resolved */
27
39
  isPending: boolean
28
40
  }
29
41
 
30
42
  /**
31
43
  * @public
32
44
  *
33
- * Returns the projection values of a document (specified via a `DocumentHandle`),
34
- * based on the provided projection string. These values are live and will update in realtime.
35
- * To reduce unnecessary network requests for resolving the projection values, an optional `ref` can be passed to the hook so that projection
36
- * resolution will only occur if the `ref` is intersecting the current viewport.
45
+ * Returns the projected values of a document based on the provided projection string.
46
+ * These values are live and will update in realtime.
47
+ * To optimize network requests, an optional `ref` can be passed to only resolve the projection
48
+ * when the referenced element is intersecting the viewport.
37
49
  *
38
50
  * @category Documents
39
- * @param options - The document handle for the document you want to project values from, the projection string, and an optional ref
40
- * @returns The projection values for the given document and a boolean to indicate whether the resolution is pending
51
+ * @remarks
52
+ * This hook has multiple signatures allowing for fine-grained control over type inference:
53
+ * - Using Typegen: Infers the return type based on the `documentType`, `dataset`, `projectId`, and `projection`.
54
+ * - Using explicit type parameter: Allows specifying a custom return type `TData`.
41
55
  *
42
- * @example Using a projection to render a preview of document
43
- * ```
44
- * // ProjectionComponent.jsx
45
- * export default function ProjectionComponent({ document }) {
46
- * const ref = useRef(null)
47
- * const { data: { title, coverImage, authors }, isPending } = useProjection({
48
- * ...document,
56
+ * @param options - An object containing the `DocumentHandle` properties (`documentId`, `documentType`, etc.), the `projection` string, optional `params`, and an optional `ref`.
57
+ * @returns An object containing the projection results (`data`) and a boolean indicating whether the resolution is pending (`isPending`). Note: Suspense handles initial loading states; `data` being `undefined` after initial loading means the document doesn't exist or the projection yielded no result.
58
+ */
59
+
60
+ // Overload 1: Relies on Typegen
61
+ /**
62
+ * @beta
63
+ * Fetch a projection, relying on Typegen for the return type based on the handle and projection.
64
+ *
65
+ * @category Documents
66
+ * @param options - Options including the document handle properties (`documentId`, `documentType`, etc.) and the `projection`.
67
+ * @returns The projected data, typed based on Typegen.
68
+ *
69
+ * @example Using Typegen for a book preview
70
+ * ```tsx
71
+ * // ProjectionComponent.tsx
72
+ * import {useProjection, type DocumentHandle} from '@sanity/sdk-react'
73
+ * import {useRef} from 'react'
74
+ * import {defineProjection} from 'groq'
75
+ *
76
+ * // Define props using DocumentHandle with the specific document type
77
+ * type ProjectionComponentProps = {
78
+ * doc: DocumentHandle<'book'> // Typegen knows 'book'
79
+ * }
80
+ *
81
+ * // This is required for typegen to generate the correct return type
82
+ * const myProjection = defineProjection(`{
83
+ * title,
84
+ * 'coverImage': cover.asset->url,
85
+ * 'authors': array::join(authors[]->{'name': firstName + ' ' + lastName}.name, ', ')
86
+ * }`)
87
+ *
88
+ * export default function ProjectionComponent({ doc }: ProjectionComponentProps) {
89
+ * const ref = useRef(null) // Optional ref to track viewport intersection for lazy loading
90
+ *
91
+ * // Spread the doc handle into the options
92
+ * // Typegen infers the return type based on 'book' and the projection
93
+ * const { data } = useProjection({
94
+ * ...doc, // Pass the handle properties
49
95
  * ref,
50
- * projection: `{
51
- * title,
52
- * 'coverImage': cover.asset->url,
53
- * 'authors': array::join(authors[]->{'name': firstName + ' ' + lastName + ' '}.name, ', ')
54
- * }`,
96
+ * projection: myProjection,
55
97
  * })
56
98
  *
99
+ * // Suspense handles initial load, check for data existence after
57
100
  * return (
58
- * <article ref={ref} style={{ opacity: isPending ? 0.5 : 1}}>
59
- * <h2>{title}</h2>
60
- * <img src={coverImage} alt={title} />
61
- * <p>{authors}</p>
101
+ * <article ref={ref}>
102
+ * <h2>{data.title ?? 'Untitled'}</h2>
103
+ * {data.coverImage && <img src={data.coverImage} alt={data.title} />}
104
+ * <p>{data.authors ?? 'Unknown authors'}</p>
62
105
  * </article>
63
106
  * )
64
107
  * }
65
- * ```
66
108
  *
67
- * @example Combining with useDocuments to render a collection with specific fields
109
+ * // Usage:
110
+ * // import {createDocumentHandle} from '@sanity/sdk-react'
111
+ * // const myDocHandle = createDocumentHandle({ documentId: 'book123', documentType: 'book' })
112
+ * // <Suspense fallback='Loading preview...'>
113
+ * // <ProjectionComponent doc={myDocHandle} />
114
+ * // </Suspense>
68
115
  * ```
69
- * // DocumentList.jsx
70
- * const { data } = useDocuments({ filter: '_type == "article"' })
71
- * return (
72
- * <div>
73
- * <h1>Books</h1>
74
- * <ul>
75
- * {data.map(book => (
76
- * <li key={book._id}>
77
- * <Suspense fallback='Loading…'>
78
- * <ProjectionComponent
79
- * document={book}
80
- * />
81
- * </Suspense>
82
- * </li>
83
- * ))}
84
- * </ul>
85
- * </div>
86
- * )
116
+ */
117
+ export function useProjection<
118
+ TProjection extends ValidProjection = ValidProjection,
119
+ TDocumentType extends string = string,
120
+ TDataset extends string = string,
121
+ TProjectId extends string = string,
122
+ >(
123
+ options: UseProjectionOptions<TProjection, TDocumentType, TDataset, TProjectId>,
124
+ ): UseProjectionResults<SanityProjectionResult<TProjection, TDocumentType, TDataset, TProjectId>>
125
+
126
+ // Overload 2: Explicit type provided
127
+ /**
128
+ * @beta
129
+ * Fetch a projection with an explicitly defined return type `TData`.
130
+ *
131
+ * @param options - Options including the document handle properties (`documentId`, etc.) and the `projection`.
132
+ * @returns The projected data, cast to the explicit type `TData`.
133
+ *
134
+ * @example Explicitly typing the projection result
135
+ * ```tsx
136
+ * import {useProjection, type DocumentHandle} from '@sanity/sdk-react'
137
+ * import {useRef} from 'react'
138
+ *
139
+ * interface SimpleBookPreview {
140
+ * title?: string;
141
+ * authorName?: string;
142
+ * }
143
+ *
144
+ * type BookPreviewProps = {
145
+ * doc: DocumentHandle
146
+ * }
147
+ *
148
+ * function BookPreview({ doc }: BookPreviewProps) {
149
+ * const ref = useRef(null)
150
+ * const { data } = useProjection<SimpleBookPreview>({
151
+ * ...doc,
152
+ * ref,
153
+ * projection: `{ title, 'authorName': author->name }`
154
+ * })
155
+ *
156
+ * return (
157
+ * <div ref={ref}>
158
+ * <h3>{data.title ?? 'No Title'}</h3>
159
+ * <p>By: {data.authorName ?? 'Unknown'}</p>
160
+ * </div>
161
+ * )
162
+ * }
163
+ *
164
+ * // Usage:
165
+ * // import {createDocumentHandle} from '@sanity/sdk-react'
166
+ * // const doc = createDocumentHandle({ documentId: 'abc', documentType: 'book' })
167
+ * // <Suspense fallback='Loading...'>
168
+ * // <BookPreview doc={doc} />
169
+ * // </Suspense>
87
170
  * ```
88
171
  */
172
+ export function useProjection<TData extends object>(
173
+ options: UseProjectionOptions, // Uses base options type
174
+ ): UseProjectionResults<TData>
175
+
176
+ // Implementation (no JSDoc needed here as it's covered by overloads)
89
177
  export function useProjection<TData extends object>({
90
178
  ref,
91
179
  projection,
@@ -94,7 +182,7 @@ export function useProjection<TData extends object>({
94
182
  const instance = useSanityInstance()
95
183
  const stateSource = getProjectionState<TData>(instance, {...docHandle, projection})
96
184
 
97
- if (stateSource.getCurrent().data === null) {
185
+ if (stateSource.getCurrent()?.data === null) {
98
186
  throw resolveProjection(instance, {...docHandle, projection})
99
187
  }
100
188
 
@@ -37,7 +37,7 @@ describe('useQuery', () => {
37
37
  } as StateSource<unknown>)
38
38
 
39
39
  function TestComponent() {
40
- const {data, isPending} = useQuery<string>('test query')
40
+ const {data, isPending} = useQuery({query: 'test query'})
41
41
  return (
42
42
  <div data-testid="output">
43
43
  {data} - {isPending ? 'pending' : 'not pending'}
@@ -82,7 +82,7 @@ describe('useQuery', () => {
82
82
  )
83
83
 
84
84
  function TestComponent() {
85
- const {data} = useQuery<string>('test query')
85
+ const {data} = useQuery({query: 'test query'})
86
86
  return <div data-testid="output">{data}</div>
87
87
  }
88
88
 
@@ -108,7 +108,7 @@ describe('useQuery', () => {
108
108
  const getCurrent = vi.fn(() => ref.current)
109
109
  const storeChanged$ = new Subject<void>()
110
110
 
111
- vi.mocked(getQueryState).mockImplementation((_instance, query) => {
111
+ vi.mocked(getQueryState).mockImplementation((_instance, {query}) => {
112
112
  if (query === 'query1') {
113
113
  return {
114
114
  getCurrent: vi.fn().mockReturnValue('data1'),
@@ -146,7 +146,7 @@ describe('useQuery', () => {
146
146
 
147
147
  function WrapperComponent() {
148
148
  const [query, setQuery] = useState('query1')
149
- const {data, isPending} = useQuery<string>(query)
149
+ const {data, isPending} = useQuery<string>({query})
150
150
  return (
151
151
  <div>
152
152
  <div data-testid="output">
@@ -5,71 +5,143 @@ import {
5
5
  type QueryOptions,
6
6
  resolveQuery,
7
7
  } from '@sanity/sdk'
8
+ import {type SanityQueryResult} from 'groq'
8
9
  import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
9
10
 
10
11
  import {useSanityInstance} from '../context/useSanityInstance'
11
12
 
13
+ // Overload 1: Inferred Type (using Typegen)
12
14
  /**
13
- * Executes GROQ queries against a Sanity dataset.
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.
14
18
  *
15
- * This hook provides a convenient way to fetch and subscribe to real-time updates
16
- * for your Sanity content. Changes made to the dataset's content will trigger
17
- * automatic updates.
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).
18
21
  *
19
- * @remarks
20
- * The returned `isPending` flag indicates when a React transition is in progress,
21
- * which can be used to show loading states for query changes.
22
+ * @example Basic usage (Inferred Type)
23
+ * ```tsx
24
+ * import {useQuery} from '@sanity/sdk-react'
25
+ * import {defineQuery} from 'groq'
22
26
  *
23
- * @beta
24
- * @category GROQ
25
- * @param query - GROQ query string to execute
26
- * @param options - Optional configuration for the query, including projectId and dataset
27
- * @returns Object containing the query result and a pending state flag
27
+ * const myQuery = defineQuery(`*[_type == "movie"]{_id, title}`)
28
28
  *
29
- * @example Basic usage
30
- * ```tsx
31
- * const {data, isPending} = useQuery<Movie[]>('*[_type == "movie"]')
32
- * ```
29
+ * function MovieList() {
30
+ * // Typegen infers the return type for data
31
+ * const {data} = useQuery({ query: myQuery })
33
32
  *
34
- * @example Using parameters
35
- * ```tsx
36
- * // With parameters
37
- * const {data} = useQuery<Movie>('*[_type == "movie" && _id == $id][0]', {
38
- * params: { id: 'movie-123' }
39
- * })
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
40
43
  * ```
41
44
  *
42
- * @example Query from a specific project/dataset
45
+ * @example Using parameters (Inferred Type)
43
46
  * ```tsx
44
- * // Specify which project and dataset to query
45
- * const {data} = useQuery<Movie[]>('*[_type == "movie"]', {
46
- * projectId: 'abc123',
47
- * dataset: 'production'
48
- * })
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
+ * }
49
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`.
50
86
  *
51
- * @example With a loading state for transitions
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
52
90
  * ```tsx
53
- * const {data, isPending} = useQuery<Movie[]>('*[_type == "movie"]')
54
- * return (
55
- * <div>
56
- * {isPending && <div>Updating...</div>}
57
- * <ul>
58
- * {data.map(movie => <li key={movie._id}>{movie.title}</li>)}
59
- * </ul>
60
- * </div>
61
- * )
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
+ * }
62
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.
63
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
64
135
  */
65
- export function useQuery<T>(query: string, options?: QueryOptions): {data: T; isPending: boolean} {
136
+ export function useQuery(options: QueryOptions): {data: unknown; isPending: boolean} {
137
+ // Implementation returns unknown, overloads define specifics
66
138
  const instance = useSanityInstance(options)
67
139
 
68
140
  // Use React's useTransition to avoid UI jank when queries change
69
141
  const [isPending, startTransition] = useTransition()
70
142
 
71
143
  // Get the unique key for this query and its options
72
- const queryKey = getQueryKey(query, options)
144
+ const queryKey = getQueryKey(options)
73
145
  // Use a deferred state to avoid immediate re-renders when the query changes
74
146
  const [deferredQueryKey, setDeferredQueryKey] = useState(queryKey)
75
147
  // Parse the deferred query key back into a query and options
@@ -95,7 +167,7 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
95
167
 
96
168
  // Get the state source for this query from the query store
97
169
  const {getCurrent, subscribe} = useMemo(
98
- () => getQueryState(instance, deferred.query, deferred.options),
170
+ () => getQueryState(instance, deferred),
99
171
  [instance, deferred],
100
172
  )
101
173
 
@@ -111,11 +183,11 @@ export function useQuery<T>(query: string, options?: QueryOptions): {data: T; is
111
183
  // Thus, the promise thrown here uses a stable abort signal, ensuring correct behavior.
112
184
  const currentSignal = ref.current.signal
113
185
  // eslint-disable-next-line react-compiler/react-compiler
114
- throw resolveQuery(instance, deferred.query, {...deferred.options, signal: currentSignal})
186
+ throw resolveQuery(instance, {...deferred, signal: currentSignal})
115
187
  }
116
188
 
117
189
  // Subscribe to updates and get the current data
118
190
  // useSyncExternalStore ensures the component re-renders when the data changes
119
- const data = useSyncExternalStore(subscribe, getCurrent) as T
191
+ const data = useSyncExternalStore(subscribe, getCurrent) as SanityQueryResult
120
192
  return useMemo(() => ({data, isPending}), [data, isPending])
121
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
+ })