@sanity/sdk-react 3.0.0-rc.1 → 3.0.0-rc.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.
Files changed (57) hide show
  1. package/dist/index.d.ts +2289 -2702
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +4315 -1915
  4. package/dist/index.js.map +1 -1
  5. package/package.json +13 -14
  6. package/src/_exports/sdk-react.ts +9 -6
  7. package/src/components/SanityApp.tsx +2 -3
  8. package/src/components/auth/AuthBoundary.test.tsx +1 -19
  9. package/src/components/auth/AuthBoundary.tsx +2 -1
  10. package/src/components/auth/LoginError.tsx +2 -3
  11. package/src/context/ComlinkTokenRefresh.tsx +3 -5
  12. package/src/context/ResourceProvider.test.tsx +21 -0
  13. package/src/context/ResourceProvider.tsx +4 -1
  14. package/src/hooks/agent/agentActions.test.tsx +1 -1
  15. package/src/hooks/agent/agentActions.ts +1 -1
  16. package/src/hooks/agent/useAgentResourceContext.ts +1 -1
  17. package/src/hooks/comlink/useFrameConnection.test.tsx +3 -2
  18. package/src/hooks/comlink/useFrameConnection.ts +1 -1
  19. package/src/hooks/comlink/useWindowConnection.test.tsx +4 -3
  20. package/src/hooks/comlink/useWindowConnection.ts +2 -3
  21. package/src/hooks/comments/useCommentActions.test.tsx +150 -0
  22. package/src/hooks/comments/useCommentActions.ts +109 -0
  23. package/src/hooks/comments/useCommentList.ts +79 -0
  24. package/src/hooks/comments/useCommentThreads.test.tsx +107 -0
  25. package/src/hooks/comments/useCommentThreads.ts +73 -0
  26. package/src/hooks/comments/useComments.test.tsx +242 -0
  27. package/src/hooks/comments/useComments.ts +59 -0
  28. package/src/hooks/context/useSanityInstance.test.tsx +2 -54
  29. package/src/hooks/context/useSanityInstance.ts +2 -24
  30. package/src/hooks/dashboard/useManageFavorite.ts +1 -1
  31. package/src/hooks/dashboard/useRecordDocumentHistoryEvent.ts +2 -1
  32. package/src/hooks/documents/useDocuments.ts +2 -7
  33. package/src/hooks/helpers/useNormalizedResourceOptions.test.tsx +0 -27
  34. package/src/hooks/helpers/useNormalizedResourceOptions.ts +14 -25
  35. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +2 -7
  36. package/src/hooks/presence/usePresence.ts +14 -1
  37. package/src/hooks/presence/usePresenceForDocument.test.tsx +141 -0
  38. package/src/hooks/presence/usePresenceForDocument.ts +104 -0
  39. package/src/hooks/presence/useReportPresence.test.tsx +202 -0
  40. package/src/hooks/presence/useReportPresence.ts +170 -0
  41. package/src/hooks/preview/useDocumentPreview.tsx +2 -6
  42. package/src/hooks/projects/useProjects.ts +0 -8
  43. package/src/hooks/query/useQuery.ts +3 -8
  44. package/src/hooks/releases/useActiveReleases.ts +2 -2
  45. package/src/hooks/releases/useAllReleases.ts +2 -2
  46. package/src/hooks/users/useUser.ts +2 -8
  47. package/src/hooks/users/useUsers.ts +1 -2
  48. package/src/hooks/applications/useCreateUserApplication.test-d.ts +0 -14
  49. package/src/hooks/applications/useCreateUserApplication.ts +0 -11
  50. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +0 -14
  51. package/src/hooks/applications/useDeleteUserApplication.ts +0 -11
  52. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +0 -14
  53. package/src/hooks/applications/useUpdateUserApplication.ts +0 -11
  54. package/src/hooks/applications/useUserApplication.test-d.ts +0 -11
  55. package/src/hooks/applications/useUserApplication.ts +0 -14
  56. package/src/hooks/applications/useUserApplications.test-d.ts +0 -11
  57. package/src/hooks/applications/useUserApplications.ts +0 -14
@@ -0,0 +1,79 @@
1
+ import {type CommentsOptions, type SanityInstance, type StateSource} from '@sanity/sdk'
2
+ import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
3
+ import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
4
+
5
+ import {useSanityInstance} from '../context/useSanityInstance'
6
+ import {
7
+ useNormalizedResourceOptions,
8
+ type WithResourceNameSupport,
9
+ } from '../helpers/useNormalizedResourceOptions'
10
+ import {trackHookUsage} from '../helpers/useTrackHookUsage'
11
+
12
+ /** The pair of core functions backing one read hook. */
13
+ export interface CommentListSource<T> {
14
+ getState: (instance: SanityInstance, options: CommentsOptions) => StateSource<T | undefined>
15
+ resolve: (
16
+ instance: SanityInstance,
17
+ options: CommentsOptions & {signal?: AbortSignal},
18
+ ) => Promise<T>
19
+ }
20
+
21
+ /**
22
+ * Shared body of {@link useComments} and {@link useCommentThreads}.
23
+ *
24
+ * Suspends until the first snapshot arrives. Changing documents or filters
25
+ * happens in a transition, so the list already on screen stays put and
26
+ * `isPending` reports the swap instead of the component suspending again. The
27
+ * previous read is aborted, which drops its listener when nothing else is
28
+ * reading it.
29
+ *
30
+ * @internal
31
+ */
32
+ export function useCommentList<T>(
33
+ hookName: string,
34
+ options: WithResourceNameSupport<CommentsOptions>,
35
+ {getState, resolve}: CommentListSource<T>,
36
+ ): {value: T; isPending: boolean} {
37
+ const instance = useSanityInstance()
38
+ trackHookUsage(instance, hookName)
39
+
40
+ const normalized = useNormalizedResourceOptions(options)
41
+ const [isPending, startTransition] = useTransition()
42
+
43
+ const key = getCommentsOptionsKey(normalized)
44
+ // Held one render behind `key`, so the swap can happen inside a transition.
45
+ const [deferredKey, setDeferredKey] = useState(key)
46
+ const abortRef = useRef<AbortController>(new AbortController())
47
+
48
+ useEffect(() => {
49
+ if (key === deferredKey) return
50
+
51
+ startTransition(() => {
52
+ if (!abortRef.current.signal.aborted) {
53
+ abortRef.current.abort()
54
+ abortRef.current = new AbortController()
55
+ }
56
+ setDeferredKey(key)
57
+ })
58
+ }, [deferredKey, key])
59
+
60
+ const deferred = useMemo(() => parseCommentsOptionsKey(deferredKey), [deferredKey])
61
+ const {getCurrent, subscribe} = useMemo(
62
+ () => getState(instance, deferred),
63
+ [deferred, getState, instance],
64
+ )
65
+
66
+ if (getCurrent() === undefined) {
67
+ // Reading the ref mid-render is safe here: React runs no effects for a
68
+ // render that suspends, so the signal captured now cannot be swapped
69
+ // underneath this pass.
70
+ const currentSignal = abortRef.current.signal
71
+
72
+ // eslint-disable-next-line react-hooks/refs -- intentional during a suspended render; see above
73
+ throw resolve(instance, {...deferred, signal: currentSignal})
74
+ }
75
+
76
+ // Not memoised: both callers destructure this immediately and memoise their
77
+ // own result object, so a stable identity here would never be observed.
78
+ return {value: useSyncExternalStore(subscribe, getCurrent) as T, isPending}
79
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ type CommentThread,
3
+ getCommentThreadsState,
4
+ resolveCommentThreads,
5
+ type StateSource,
6
+ } from '@sanity/sdk'
7
+ import {act, render, screen} from '@testing-library/react'
8
+ import {Suspense} from 'react'
9
+ import {type Observable} from 'rxjs'
10
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {ResourceProvider} from '../../context/ResourceProvider'
13
+ import {useCommentThreads} from './useCommentThreads'
14
+
15
+ vi.mock('@sanity/sdk', async (importOriginal) => {
16
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
17
+ return {...original, getCommentThreadsState: vi.fn(), resolveCommentThreads: vi.fn()}
18
+ })
19
+
20
+ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
21
+
22
+ function thread(threadId: string) {
23
+ return {threadId, commentsCount: 2, fieldPath: ''} as CommentThread
24
+ }
25
+
26
+ function mockSource(getCurrent: () => CommentThread[] | undefined) {
27
+ vi.mocked(getCommentThreadsState).mockReturnValue({
28
+ getCurrent,
29
+ subscribe: vi.fn(() => () => {}),
30
+ get observable(): Observable<CommentThread[] | undefined> {
31
+ throw new Error('Not implemented')
32
+ },
33
+ } as StateSource<CommentThread[] | undefined>)
34
+ }
35
+
36
+ function Wrapper({children}: {children: React.ReactNode}) {
37
+ return (
38
+ <ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
39
+ <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
40
+ </ResourceProvider>
41
+ )
42
+ }
43
+
44
+ describe('useCommentThreads', () => {
45
+ beforeEach(() => {
46
+ vi.resetAllMocks()
47
+ })
48
+
49
+ it('renders the threads once they are loaded', () => {
50
+ const loaded = [thread('t1'), thread('t2')]
51
+ mockSource(() => loaded)
52
+
53
+ function TestComponent() {
54
+ const {threads, isPending} = useCommentThreads(HANDLE)
55
+ return <div data-testid="out">{`${threads.length} ${isPending ? 'pending' : 'idle'}`}</div>
56
+ }
57
+
58
+ render(<TestComponent />, {wrapper: Wrapper})
59
+
60
+ expect(screen.getByTestId('out').textContent).toBe('2 idle')
61
+ })
62
+
63
+ it('suspends until the threads are available', async () => {
64
+ const loaded = [thread('t1')]
65
+ const ref: {current: CommentThread[] | undefined} = {current: undefined}
66
+ mockSource(() => ref.current)
67
+
68
+ let settle: () => void = () => {}
69
+ vi.mocked(resolveCommentThreads).mockReturnValue(
70
+ new Promise<CommentThread[]>((resolve) => {
71
+ settle = () => resolve(loaded)
72
+ }),
73
+ )
74
+
75
+ function TestComponent() {
76
+ const {threads} = useCommentThreads(HANDLE)
77
+ return <div data-testid="out">{threads.length}</div>
78
+ }
79
+
80
+ render(<TestComponent />, {wrapper: Wrapper})
81
+ expect(screen.getByTestId('suspended')).toBeInTheDocument()
82
+
83
+ await act(async () => {
84
+ ref.current = loaded
85
+ settle()
86
+ })
87
+
88
+ expect(screen.getByTestId('out').textContent).toBe('1')
89
+ })
90
+
91
+ it('narrows to one field when asked', () => {
92
+ const loaded: CommentThread[] = []
93
+ mockSource(() => loaded)
94
+
95
+ function TestComponent() {
96
+ useCommentThreads({...HANDLE, fieldPath: 'title'})
97
+ return null
98
+ }
99
+
100
+ render(<TestComponent />, {wrapper: Wrapper})
101
+
102
+ expect(getCommentThreadsState).toHaveBeenCalledWith(
103
+ expect.anything(),
104
+ expect.objectContaining({fieldPath: 'title'}),
105
+ )
106
+ })
107
+ })
@@ -0,0 +1,73 @@
1
+ import {
2
+ type CommentsOptions,
3
+ type CommentThread,
4
+ getCommentThreadsState,
5
+ resolveCommentThreads,
6
+ } from '@sanity/sdk'
7
+ import {useMemo} from 'react'
8
+
9
+ import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
10
+ import {type CommentListSource, useCommentList} from './useCommentList'
11
+
12
+ /**
13
+ * @public
14
+ * @category Types
15
+ */
16
+ export interface UseCommentThreadsResult {
17
+ /** Newest thread first, each with its replies oldest first. */
18
+ threads: CommentThread[]
19
+ /** True while switching to a different document or filter. */
20
+ isPending: boolean
21
+ }
22
+
23
+ const SOURCE: CommentListSource<CommentThread[]> = {
24
+ getState: getCommentThreadsState,
25
+ resolve: resolveCommentThreads,
26
+ }
27
+
28
+ /**
29
+ * Reads a document's comments grouped into threads.
30
+ *
31
+ * A thread is one comment plus its replies. Its `status` and `fieldPath` come
32
+ * from the first comment, so filtering by either selects whole threads rather
33
+ * than stray replies.
34
+ *
35
+ * Unlike the Studio, every thread is returned. The Studio hides threads whose
36
+ * field has left the schema or is hidden by a conditional, which it can do
37
+ * because it has the schema to check against. Inspect `fieldPath` yourself if
38
+ * your app needs to do the same.
39
+ *
40
+ * @category Comments
41
+ * @function
42
+ * @param options - The document to read, optionally narrowed by `fieldPath` or `status`
43
+ * @returns The matching threads, and whether a switch is in flight
44
+ *
45
+ * @example Render the open threads on a document
46
+ * ```tsx
47
+ * function Threads({documentId}: {documentId: string}) {
48
+ * const {threads} = useCommentThreads({
49
+ * documentId,
50
+ * documentType: 'article',
51
+ * status: 'open',
52
+ * })
53
+ *
54
+ * return (
55
+ * <ul>
56
+ * {threads.map((thread) => (
57
+ * <li key={thread.threadId}>
58
+ * {thread.fieldPath || 'Document'} — {thread.commentsCount} comments
59
+ * </li>
60
+ * ))}
61
+ * </ul>
62
+ * )
63
+ * }
64
+ * ```
65
+ *
66
+ * @public
67
+ */
68
+ export function useCommentThreads(
69
+ options: WithResourceNameSupport<CommentsOptions>,
70
+ ): UseCommentThreadsResult {
71
+ const {value, isPending} = useCommentList('useCommentThreads', options, SOURCE)
72
+ return useMemo(() => ({threads: value, isPending}), [isPending, value])
73
+ }
@@ -0,0 +1,242 @@
1
+ import {
2
+ type Comment,
3
+ type CommentsOptions,
4
+ getCommentsState,
5
+ resolveComments,
6
+ type StateSource,
7
+ } from '@sanity/sdk'
8
+ import {act, render, screen} from '@testing-library/react'
9
+ import {Suspense} from 'react'
10
+ import {type Observable, Subject} from 'rxjs'
11
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
12
+
13
+ import {ResourceProvider} from '../../context/ResourceProvider'
14
+ import {ResourcesContext} from '../../context/ResourcesContext'
15
+ import {useComments} from './useComments'
16
+
17
+ vi.mock('@sanity/sdk', async (importOriginal) => {
18
+ const original = await importOriginal<typeof import('@sanity/sdk')>()
19
+ return {...original, getCommentsState: vi.fn(), resolveComments: vi.fn()}
20
+ })
21
+
22
+ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
23
+
24
+ function comment(id: string): Comment {
25
+ return {
26
+ id,
27
+ createdAt: '2026-01-01T00:00:00Z',
28
+ authorId: 'user-1',
29
+ message: null,
30
+ threadId: 'thread-1',
31
+ status: 'open',
32
+ documentId: 'doc-1',
33
+ documentType: 'author',
34
+ fieldPath: '',
35
+ reactions: [],
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Stands in for the store, one source per option set so a test can hold one
41
+ * document loaded and another not.
42
+ *
43
+ * `getCurrent` must hand back the same array every call for a given option set.
44
+ * Returning a fresh one sends `useSyncExternalStore` into a render loop, which
45
+ * is why the store memoises its selectors.
46
+ */
47
+ function mockSource(
48
+ getCurrent: (options: CommentsOptions) => Comment[] | undefined,
49
+ changed$?: Subject<void>,
50
+ ) {
51
+ vi.mocked(getCommentsState).mockImplementation(
52
+ (_instance, options) =>
53
+ ({
54
+ getCurrent: () => getCurrent(options),
55
+ subscribe: vi.fn((cb?: () => void) => {
56
+ const subscription = changed$?.subscribe(() => cb?.())
57
+ return () => subscription?.unsubscribe()
58
+ }),
59
+ get observable(): Observable<Comment[] | undefined> {
60
+ throw new Error('Not implemented')
61
+ },
62
+ }) as StateSource<Comment[] | undefined>,
63
+ )
64
+ }
65
+
66
+ /** Hoisted so the context value stays identical across renders. */
67
+ const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
68
+
69
+ function Wrapper({children}: {children: React.ReactNode}) {
70
+ return (
71
+ <ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
72
+ <ResourcesContext.Provider value={{other: {projectId: 'p2', dataset: 'd2'}}}>
73
+ <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
74
+ </ResourcesContext.Provider>
75
+ </ResourceProvider>
76
+ )
77
+ }
78
+
79
+ function PerspectiveWrapper({children}: {children: React.ReactNode}) {
80
+ return (
81
+ <ResourceProvider
82
+ projectId="p"
83
+ dataset="d"
84
+ perspective={RELEASE_PERSPECTIVE}
85
+ fallback={<p>Loading…</p>}
86
+ >
87
+ <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
88
+ </ResourceProvider>
89
+ )
90
+ }
91
+
92
+ describe('useComments', () => {
93
+ beforeEach(() => {
94
+ vi.resetAllMocks()
95
+ })
96
+
97
+ it('renders the comments once they are loaded', () => {
98
+ const loaded = [comment('a'), comment('b')]
99
+ mockSource(() => loaded)
100
+
101
+ function TestComponent() {
102
+ const {comments, isPending} = useComments(HANDLE)
103
+ return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
104
+ }
105
+
106
+ render(<TestComponent />, {wrapper: Wrapper})
107
+
108
+ expect(screen.getByTestId('out').textContent).toBe('2 idle')
109
+ })
110
+
111
+ it('suspends until the first snapshot arrives', async () => {
112
+ const loaded = [comment('a')]
113
+ const ref: {current: Comment[] | undefined} = {current: undefined}
114
+ const changed$ = new Subject<void>()
115
+ mockSource(() => ref.current, changed$)
116
+
117
+ let settle: () => void = () => {}
118
+ vi.mocked(resolveComments).mockReturnValue(
119
+ new Promise<Comment[]>((resolve) => {
120
+ settle = () => resolve(loaded)
121
+ }),
122
+ )
123
+
124
+ function TestComponent() {
125
+ const {comments} = useComments(HANDLE)
126
+ return <div data-testid="out">{comments.length}</div>
127
+ }
128
+
129
+ render(<TestComponent />, {wrapper: Wrapper})
130
+ expect(screen.getByTestId('suspended')).toBeInTheDocument()
131
+
132
+ await act(async () => {
133
+ ref.current = loaded
134
+ settle()
135
+ })
136
+
137
+ expect(screen.getByTestId('out').textContent).toBe('1')
138
+ })
139
+
140
+ it('passes the field path and status through to the store', () => {
141
+ const loaded: Comment[] = []
142
+ mockSource(() => loaded)
143
+
144
+ function TestComponent() {
145
+ useComments({...HANDLE, fieldPath: ['body', {_key: 'intro'}], status: 'resolved'})
146
+ return null
147
+ }
148
+
149
+ render(<TestComponent />, {wrapper: Wrapper})
150
+
151
+ expect(getCommentsState).toHaveBeenCalledWith(
152
+ expect.anything(),
153
+ expect.objectContaining({
154
+ documentId: 'doc-1',
155
+ fieldPath: 'body[_key=="intro"]',
156
+ status: 'resolved',
157
+ resource: {projectId: 'p', dataset: 'd'},
158
+ }),
159
+ )
160
+ })
161
+
162
+ it('resolves a named resource from context', () => {
163
+ const loaded: Comment[] = []
164
+ mockSource(() => loaded)
165
+
166
+ function TestComponent() {
167
+ useComments({...HANDLE, resourceName: 'other'})
168
+ return null
169
+ }
170
+
171
+ render(<TestComponent />, {wrapper: Wrapper})
172
+
173
+ expect(getCommentsState).toHaveBeenCalledWith(
174
+ expect.anything(),
175
+ expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
176
+ )
177
+ })
178
+
179
+ it('fills in the perspective from context', () => {
180
+ // Core turns a release perspective into `target.documentVersionId` and into
181
+ // the key the list is stored under, so dropping it here would read and
182
+ // write the wrong release with nothing to show that anything went wrong.
183
+ const loaded: Comment[] = []
184
+ mockSource(() => loaded)
185
+
186
+ function TestComponent() {
187
+ useComments(HANDLE)
188
+ return null
189
+ }
190
+
191
+ render(<TestComponent />, {wrapper: PerspectiveWrapper})
192
+
193
+ expect(getCommentsState).toHaveBeenCalledWith(
194
+ expect.anything(),
195
+ expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
196
+ )
197
+ })
198
+
199
+ it('keeps the previous list on screen while a different document loads', async () => {
200
+ const first = [comment('a')]
201
+ const second = [comment('b'), comment('c')]
202
+ // Only `doc-1` is loaded, so switching to `doc-2` has to suspend.
203
+ const byDocument: Record<string, Comment[] | undefined> = {'doc-1': first}
204
+ mockSource((options) => byDocument[options.documentId])
205
+
206
+ let settle: () => void = () => {}
207
+ vi.mocked(resolveComments).mockReturnValue(
208
+ new Promise<Comment[]>((resolve) => {
209
+ settle = () => resolve(second)
210
+ }),
211
+ )
212
+
213
+ function TestComponent({documentId}: {documentId: string}) {
214
+ const {comments, isPending} = useComments({...HANDLE, documentId})
215
+ return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
216
+ }
217
+
218
+ const {rerender} = render(<TestComponent documentId="doc-1" />, {wrapper: Wrapper})
219
+ expect(screen.getByTestId('out').textContent).toBe('1 idle')
220
+
221
+ await act(async () => {
222
+ rerender(<TestComponent documentId="doc-2" />)
223
+ })
224
+
225
+ // The swap happens inside a transition, so the render that suspends is
226
+ // thrown away rather than falling back: `doc-1` stays on screen and
227
+ // `isPending` is what reports the switch.
228
+ expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
229
+ expect(screen.getByTestId('out').textContent).toBe('1 pending')
230
+ expect(getCommentsState).toHaveBeenCalledWith(
231
+ expect.anything(),
232
+ expect.objectContaining({documentId: 'doc-2'}),
233
+ )
234
+
235
+ await act(async () => {
236
+ byDocument['doc-2'] = second
237
+ settle()
238
+ })
239
+
240
+ expect(screen.getByTestId('out').textContent).toBe('2 idle')
241
+ })
242
+ })
@@ -0,0 +1,59 @@
1
+ import {type Comment, type CommentsOptions, getCommentsState, resolveComments} from '@sanity/sdk'
2
+ import {useMemo} from 'react'
3
+
4
+ import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
5
+ import {type CommentListSource, useCommentList} from './useCommentList'
6
+
7
+ /**
8
+ * @public
9
+ * @category Types
10
+ */
11
+ export interface UseCommentsResult {
12
+ /** Every matching comment, newest first, replies included. */
13
+ comments: Comment[]
14
+ /** True while switching to a different document or filter. */
15
+ isPending: boolean
16
+ }
17
+
18
+ const SOURCE: CommentListSource<Comment[]> = {
19
+ getState: getCommentsState,
20
+ resolve: resolveComments,
21
+ }
22
+
23
+ /**
24
+ * Reads a document's comments and keeps them up to date.
25
+ *
26
+ * Comments are shared with the Studio: they live in the project's comments
27
+ * dataset, so a thread started here shows up there and the other way round. The
28
+ * list is flat, replies included; reach for {@link useCommentThreads} to read it
29
+ * grouped.
30
+ *
31
+ * Suspends until the comments have loaded. Switching document or filter is a
32
+ * transition, so the previous list stays on screen and `isPending` goes true
33
+ * rather than the component suspending again.
34
+ *
35
+ * @category Comments
36
+ * @function
37
+ * @param options - The document to read, optionally narrowed by `fieldPath` or `status`
38
+ * @returns The matching comments, and whether a switch is in flight
39
+ *
40
+ * @example Count the open threads on a field
41
+ * ```tsx
42
+ * function TitleCommentCount({documentId}: {documentId: string}) {
43
+ * const {comments} = useComments({
44
+ * documentId,
45
+ * documentType: 'article',
46
+ * fieldPath: 'title',
47
+ * status: 'open',
48
+ * })
49
+ *
50
+ * return <span>{comments.length}</span>
51
+ * }
52
+ * ```
53
+ *
54
+ * @public
55
+ */
56
+ export function useComments(options: WithResourceNameSupport<CommentsOptions>): UseCommentsResult {
57
+ const {value, isPending} = useCommentList('useComments', options, SOURCE)
58
+ return useMemo(() => ({comments: value, isPending}), [isPending, value])
59
+ }
@@ -1,7 +1,7 @@
1
- import {createSanityInstance, type SanityConfig, type SanityInstance} from '@sanity/sdk'
1
+ import {createSanityInstance, type SanityInstance} from '@sanity/sdk'
2
2
  import {renderHook} from '@testing-library/react'
3
3
  import {type ReactNode} from 'react'
4
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+ import {describe, expect, it} from 'vitest'
5
5
 
6
6
  import {SanityInstanceContext} from '../../context/SanityInstanceContext'
7
7
  import {useSanityInstance} from './useSanityInstance'
@@ -49,56 +49,4 @@ describe('useSanityInstance', () => {
49
49
  // Should return the instance
50
50
  expect(result.current).toBe(instance)
51
51
  })
52
-
53
- describe('deprecated config parameter', () => {
54
- let warnSpy: ReturnType<typeof vi.spyOn>
55
-
56
- beforeEach(() => {
57
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
58
- })
59
-
60
- afterEach(() => {
61
- warnSpy.mockRestore()
62
- })
63
-
64
- it('should return the current context instance regardless of config', () => {
65
- const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
66
- const requestedConfig: SanityConfig = {projectId: 'test-project', dataset: 'test-dataset'}
67
-
68
- const {result} = renderHook(() => useSanityInstance(requestedConfig), {
69
- wrapper: createWrapper(instance),
70
- })
71
-
72
- expect(result.current).toBe(instance)
73
- })
74
-
75
- it('should throw if no instance in context even when config is provided', () => {
76
- expect(() => {
77
- renderHook(() => useSanityInstance({projectId: 'test'}), {
78
- wrapper: createWrapper(null),
79
- })
80
- }).toThrow('SanityInstance context not found')
81
- })
82
-
83
- it('warns once when a config argument is passed', () => {
84
- const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
85
- const {rerender} = renderHook(() => useSanityInstance({projectId: 'test-project'}), {
86
- wrapper: createWrapper(instance),
87
- })
88
-
89
- expect(warnSpy).toHaveBeenCalledTimes(1)
90
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[useSanityInstance]'))
91
-
92
- rerender()
93
- rerender()
94
- expect(warnSpy).toHaveBeenCalledTimes(1)
95
- })
96
-
97
- it('does not warn when no config is passed', () => {
98
- const instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
99
- renderHook(() => useSanityInstance(), {wrapper: createWrapper(instance)})
100
-
101
- expect(warnSpy).not.toHaveBeenCalled()
102
- })
103
- })
104
52
  })
@@ -1,17 +1,14 @@
1
- import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
1
+ import {type SanityInstance} from '@sanity/sdk'
2
2
  import {useContext} from 'react'
3
3
 
4
4
  import {SanityInstanceContext} from '../../context/SanityInstanceContext'
5
5
 
6
- const warnedCallers = new Set<string>()
7
-
8
6
  /**
9
7
  * Retrieves the current Sanity instance from context
10
8
  *
11
9
  * @public
12
10
  *
13
11
  * @category Platform
14
- * @param config - Deprecated. Formerly used to match against the instance hierarchy.
15
12
  * @returns The current Sanity instance
16
13
  *
17
14
  * @remarks
@@ -26,26 +23,7 @@ const warnedCallers = new Set<string>()
26
23
  *
27
24
  * @throws Error if no SanityInstance is found in context
28
25
  */
29
- export const useSanityInstance = (
30
- /**
31
- * @deprecated Passing a config to match against the instance hierarchy is deprecated.
32
- * Use `useSanityInstance()` without arguments instead.
33
- */
34
- config?: SanityConfig,
35
- ): SanityInstance => {
36
- if (config !== undefined) {
37
- const caller = new Error().stack?.split('\n')[2]?.trim() ?? 'unknown'
38
- if (!warnedCallers.has(caller)) {
39
- warnedCallers.add(caller)
40
- // eslint-disable-next-line no-console
41
- console.warn(
42
- '[useSanityInstance] Passing a config argument is deprecated and has no effect. ' +
43
- 'SDK apps use a single instance for all resources, so the config argument is no longer needed. ' +
44
- 'Call useSanityInstance() without arguments instead, or useResource() to get your currently active resource.',
45
- )
46
- }
47
- }
48
-
26
+ export const useSanityInstance = (): SanityInstance => {
49
27
  const instance = useContext(SanityInstanceContext)
50
28
 
51
29
  if (!instance) {
@@ -9,10 +9,10 @@ import {
9
9
  import {
10
10
  type DocumentHandle,
11
11
  type FavoriteStatusResponse,
12
- type FrameMessage,
13
12
  getFavoritesState,
14
13
  resolveFavoritesState,
15
14
  } from '@sanity/sdk'
15
+ import {type FrameMessage} from '@sanity/sdk/comlink'
16
16
  import {useCallback, useMemo, useSyncExternalStore} from 'react'
17
17
 
18
18
  import {useWindowConnection} from '../comlink/useWindowConnection'