@sanity/sdk-react 3.11.0-next.20261029105031 → 3.11.0

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 +13 -9
  2. package/dist/_exports/collaboration.d.ts +255 -0
  3. package/dist/_exports/collaboration.d.ts.map +1 -0
  4. package/dist/_exports/collaboration.js +244 -0
  5. package/dist/_exports/collaboration.js.map +1 -0
  6. package/dist/_exports/dashboard.js +2 -1
  7. package/dist/_exports/dashboard.js.map +1 -1
  8. package/dist/index.d.ts +16 -22
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +49 -159
  11. package/dist/index.js.map +1 -1
  12. package/dist/useCommentList-CXi_IIwA.js +167 -0
  13. package/dist/useCommentList-CXi_IIwA.js.map +1 -0
  14. package/dist/useNormalizedResourceOptions-BKg7aOKA.d.ts +22 -0
  15. package/dist/useNormalizedResourceOptions-BKg7aOKA.d.ts.map +1 -0
  16. package/dist/useSanityInstance-C0a_Ou_p.js +10 -0
  17. package/dist/useSanityInstance-C0a_Ou_p.js.map +1 -0
  18. package/dist/{useStudioWorkspacesByProjectIdDataset-FRofq7KL.js → useStudioWorkspacesByProjectIdDataset-Cx-Q44d0.js} +4 -8
  19. package/dist/useStudioWorkspacesByProjectIdDataset-Cx-Q44d0.js.map +1 -0
  20. package/package.json +3 -2
  21. package/src/_exports/collaboration.ts +13 -0
  22. package/src/_exports/sdk-react.ts +6 -3
  23. package/src/hooks/comments/legacy/noCommentsError.ts +20 -0
  24. package/src/hooks/comments/legacy/useCommentActions.test.tsx +150 -0
  25. package/src/hooks/comments/legacy/useCommentActions.ts +116 -0
  26. package/src/hooks/comments/legacy/useCommentThreads.test.tsx +107 -0
  27. package/src/hooks/comments/legacy/useCommentThreads.ts +83 -0
  28. package/src/hooks/comments/legacy/useComments.test.tsx +242 -0
  29. package/src/hooks/comments/legacy/useComments.ts +68 -0
  30. package/src/hooks/comments/useCommentActions.test.tsx +33 -13
  31. package/src/hooks/comments/useCommentActions.ts +31 -11
  32. package/src/hooks/comments/useCommentList.ts +34 -19
  33. package/src/hooks/comments/useCommentThreads.test.tsx +207 -19
  34. package/src/hooks/comments/useCommentThreads.ts +33 -27
  35. package/src/hooks/comments/useComments.test.tsx +33 -126
  36. package/src/hooks/comments/useComments.ts +31 -12
  37. package/src/hooks/comments/useCommentsQuery.test.tsx +259 -0
  38. package/src/hooks/comments/useCommentsQuery.ts +105 -0
  39. package/src/hooks/document/useEditDocument.ts +0 -1
  40. package/dist/useStudioWorkspacesByProjectIdDataset-FRofq7KL.js.map +0 -1
@@ -1,74 +1,80 @@
1
+ import {getDocumentCommentsOptionsKey, parseDocumentCommentsOptionsKey} from '@sanity/sdk/_internal'
1
2
  import {
2
3
  type CommentsOptions,
3
4
  type CommentThread,
5
+ getCommentsErrorState,
4
6
  getCommentThreadsState,
5
7
  resolveCommentThreads,
6
- } from '@sanity/sdk'
8
+ } from '@sanity/sdk/collaboration'
7
9
  import {useMemo} from 'react'
8
10
 
9
11
  import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
10
12
  import {type CommentListSource, useCommentList} from './useCommentList'
11
13
 
12
14
  /**
13
- * @public
15
+ * @beta
14
16
  * @category Types
15
17
  * @category Comments
16
18
  */
17
19
  export interface UseCommentThreadsResult {
18
- /** Newest thread first, each with its replies oldest first. */
20
+ /** Matching threads, newest first, each with its replies oldest first. */
19
21
  threads: CommentThread[]
20
22
  /** True while switching to a different document or filter. */
21
23
  isPending: boolean
24
+ /**
25
+ * Set when the threads have stopped following the server: the listener
26
+ * failed after they had loaded, so what you are reading is the list as it
27
+ * last stood rather than as it is. Clears when a listener comes back.
28
+ */
29
+ error?: unknown
22
30
  }
23
31
 
24
- const SOURCE: CommentListSource<CommentThread[]> = {
32
+ const SOURCE: CommentListSource<CommentsOptions, CommentThread[]> = {
25
33
  getState: getCommentThreadsState,
34
+ getErrorState: getCommentsErrorState,
26
35
  resolve: resolveCommentThreads,
36
+ getKey: getDocumentCommentsOptionsKey,
37
+ parseKey: parseDocumentCommentsOptionsKey,
27
38
  }
28
39
 
29
40
  /**
30
- * Reads a document's comments grouped into threads.
41
+ * Reads a document's comment threads and keeps them up to date.
31
42
  *
32
- * A thread is one comment plus its replies. Its `status` and `fieldPath` come
33
- * from the first comment, so filtering by either selects whole threads rather
34
- * than stray replies.
43
+ * A thread carries its first comment plus its replies, and `status` and
44
+ * `fieldPath` come from that first comment, so filtering by either selects
45
+ * whole threads. Use {@link useComments} to read the same list flat, `scope` to
46
+ * say which of the document's comments to cover — by default it follows the
47
+ * perspective in view — and {@link useCommentsQuery} when the question is not
48
+ * "comments on this document".
35
49
  *
36
- * Unlike the Studio, every thread is returned. The Studio hides threads whose
37
- * field has left the schema or is hidden by a conditional, which it can do
38
- * because it has the schema to check against. Inspect `fieldPath` yourself if
39
- * your app needs to do the same.
50
+ * Suspends until the comments have loaded. Switching document or filter is a
51
+ * transition, so the previous list stays on screen and `isPending` goes true
52
+ * rather than the component suspending again.
40
53
  *
41
54
  * @category Comments
42
55
  * @function
43
- * @param options - The document to read, optionally narrowed by `fieldPath` or `status`
56
+ * @param options - The document to read, optionally narrowed by `fieldPath`, `status`, or `scope`
44
57
  * @returns The matching threads, and whether a switch is in flight
45
58
  *
46
- * @example Render the open threads on a document
59
+ * @example Count the open threads on a field
47
60
  * ```tsx
48
- * function Threads({documentId}: {documentId: string}) {
61
+ * function TitleCommentCount({documentId}: {documentId: string}) {
49
62
  * const {threads} = useCommentThreads({
50
63
  * documentId,
51
64
  * documentType: 'article',
65
+ * fieldPath: 'title',
52
66
  * status: 'open',
53
67
  * })
54
68
  *
55
- * return (
56
- * <ul>
57
- * {threads.map((thread) => (
58
- * <li key={thread.threadId}>
59
- * {thread.fieldPath || 'Document'} — {thread.commentsCount} comments
60
- * </li>
61
- * ))}
62
- * </ul>
63
- * )
69
+ * return <span>{threads.length}</span>
64
70
  * }
65
71
  * ```
66
72
  *
67
- * @public
73
+ * @beta
68
74
  */
69
75
  export function useCommentThreads(
70
76
  options: WithResourceNameSupport<CommentsOptions>,
71
77
  ): UseCommentThreadsResult {
72
- const {value, isPending} = useCommentList('useCommentThreads', options, SOURCE)
73
- return useMemo(() => ({threads: value, isPending}), [isPending, value])
78
+ const {value, isPending, error} = useCommentList('useCommentThreads', options, SOURCE)
79
+ return useMemo(() => ({threads: value, isPending, error}), [error, isPending, value])
74
80
  }
@@ -1,48 +1,38 @@
1
+ import {type StateSource} from '@sanity/sdk'
1
2
  import {
2
3
  type Comment,
3
4
  type CommentsOptions,
5
+ getCommentsErrorState,
4
6
  getCommentsState,
5
7
  resolveComments,
6
- type StateSource,
7
- } from '@sanity/sdk'
8
+ } from '@sanity/sdk/collaboration'
8
9
  import {act, render, screen} from '@testing-library/react'
9
10
  import {Suspense} from 'react'
10
11
  import {type Observable, Subject} from 'rxjs'
11
12
  import {beforeEach, describe, expect, it, vi} from 'vitest'
12
13
 
13
14
  import {ResourceProvider} from '../../context/ResourceProvider'
14
- import {ResourcesContext} from '../../context/ResourcesContext'
15
15
  import {useComments} from './useComments'
16
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()}
17
+ vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
18
+ const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
19
+ return {
20
+ ...original,
21
+ getCommentsState: vi.fn(),
22
+ getCommentsErrorState: vi.fn(),
23
+ resolveComments: vi.fn(),
24
+ }
20
25
  })
21
26
 
22
27
  const HANDLE = {documentId: 'doc-1', documentType: 'author'}
23
28
 
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
- }
29
+ function comment(id: string) {
30
+ return {id, fieldPath: ''} as Comment
37
31
  }
38
32
 
39
33
  /**
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.
34
+ * Stands in for the store. `getCurrent` must hand back the same array every
35
+ * call for a given option set, or `useSyncExternalStore` renders in a loop.
46
36
  */
47
37
  function mockSource(
48
38
  getCurrent: (options: CommentsOptions) => Comment[] | undefined,
@@ -61,29 +51,21 @@ function mockSource(
61
51
  },
62
52
  }) as StateSource<Comment[] | undefined>,
63
53
  )
54
+ vi.mocked(getCommentsErrorState).mockImplementation(
55
+ () =>
56
+ ({
57
+ getCurrent: () => undefined,
58
+ subscribe: vi.fn(() => () => {}),
59
+ get observable(): Observable<unknown> {
60
+ throw new Error('Not implemented')
61
+ },
62
+ }) as StateSource<unknown>,
63
+ )
64
64
  }
65
65
 
66
- /** Hoisted so the context value stays identical across renders. */
67
- const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
68
-
69
66
  function Wrapper({children}: {children: React.ReactNode}) {
70
67
  return (
71
68
  <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
69
  <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
88
70
  </ResourceProvider>
89
71
  )
@@ -137,12 +119,17 @@ describe('useComments', () => {
137
119
  expect(screen.getByTestId('out').textContent).toBe('1')
138
120
  })
139
121
 
140
- it('passes the field path and status through to the store', () => {
122
+ it('passes the field path, status, and scope through to the store', () => {
141
123
  const loaded: Comment[] = []
142
124
  mockSource(() => loaded)
143
125
 
144
126
  function TestComponent() {
145
- useComments({...HANDLE, fieldPath: ['body', {_key: 'intro'}], status: 'resolved'})
127
+ useComments({
128
+ ...HANDLE,
129
+ fieldPath: ['body', {_key: 'intro'}],
130
+ status: 'resolved',
131
+ scope: 'all',
132
+ })
146
133
  return null
147
134
  }
148
135
 
@@ -154,89 +141,9 @@ describe('useComments', () => {
154
141
  documentId: 'doc-1',
155
142
  fieldPath: 'body[_key=="intro"]',
156
143
  status: 'resolved',
144
+ scope: 'all',
157
145
  resource: {projectId: 'p', dataset: 'd'},
158
146
  }),
159
147
  )
160
148
  })
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
149
  })
@@ -1,11 +1,18 @@
1
- import {type Comment, type CommentsOptions, getCommentsState, resolveComments} from '@sanity/sdk'
1
+ import {getDocumentCommentsOptionsKey, parseDocumentCommentsOptionsKey} from '@sanity/sdk/_internal'
2
+ import {
3
+ type Comment,
4
+ type CommentsOptions,
5
+ getCommentsErrorState,
6
+ getCommentsState,
7
+ resolveComments,
8
+ } from '@sanity/sdk/collaboration'
2
9
  import {useMemo} from 'react'
3
10
 
4
11
  import {type WithResourceNameSupport} from '../helpers/useNormalizedResourceOptions'
5
12
  import {type CommentListSource, useCommentList} from './useCommentList'
6
13
 
7
14
  /**
8
- * @public
15
+ * @beta
9
16
  * @category Types
10
17
  * @category Comments
11
18
  */
@@ -14,20 +21,32 @@ export interface UseCommentsResult {
14
21
  comments: Comment[]
15
22
  /** True while switching to a different document or filter. */
16
23
  isPending: boolean
24
+ /**
25
+ * Set when the comments have stopped following the server: the listener
26
+ * failed after they had loaded, so what you are reading is the list as it
27
+ * last stood rather than as it is. Clears when a listener comes back.
28
+ */
29
+ error?: unknown
17
30
  }
18
31
 
19
- const SOURCE: CommentListSource<Comment[]> = {
32
+ const SOURCE: CommentListSource<CommentsOptions, Comment[]> = {
20
33
  getState: getCommentsState,
34
+ getErrorState: getCommentsErrorState,
21
35
  resolve: resolveComments,
36
+ getKey: getDocumentCommentsOptionsKey,
37
+ parseKey: parseDocumentCommentsOptionsKey,
22
38
  }
23
39
 
24
40
  /**
25
41
  * Reads a document's comments and keeps them up to date.
26
42
  *
27
- * Comments are shared with the Studio: they live in the project's comments
28
- * dataset, so a thread started here shows up there and the other way round. The
29
- * list is flat, replies included; reach for {@link useCommentThreads} to read it
30
- * grouped.
43
+ * The list is flat, replies included, which suits a count or a feed. Reach for
44
+ * {@link useCommentThreads} to read the same comments grouped, where a reply
45
+ * sits under the comment it answers and filtering by `status` or `fieldPath`
46
+ * selects whole threads rather than individual comments. Both read the same
47
+ * document and share a listener, so using them side by side costs nothing
48
+ * extra. {@link useCommentsQuery} covers anything that is not one document's
49
+ * comments.
31
50
  *
32
51
  * Suspends until the comments have loaded. Switching document or filter is a
33
52
  * transition, so the previous list stays on screen and `isPending` goes true
@@ -35,10 +54,10 @@ const SOURCE: CommentListSource<Comment[]> = {
35
54
  *
36
55
  * @category Comments
37
56
  * @function
38
- * @param options - The document to read, optionally narrowed by `fieldPath` or `status`
57
+ * @param options - The document to read, optionally narrowed by `fieldPath`, `status`, or `scope`
39
58
  * @returns The matching comments, and whether a switch is in flight
40
59
  *
41
- * @example Count the open threads on a field
60
+ * @example Count the open comments on a field
42
61
  * ```tsx
43
62
  * function TitleCommentCount({documentId}: {documentId: string}) {
44
63
  * const {comments} = useComments({
@@ -52,9 +71,9 @@ const SOURCE: CommentListSource<Comment[]> = {
52
71
  * }
53
72
  * ```
54
73
  *
55
- * @public
74
+ * @beta
56
75
  */
57
76
  export function useComments(options: WithResourceNameSupport<CommentsOptions>): UseCommentsResult {
58
- const {value, isPending} = useCommentList('useComments', options, SOURCE)
59
- return useMemo(() => ({comments: value, isPending}), [isPending, value])
77
+ const {value, isPending, error} = useCommentList('useComments', options, SOURCE)
78
+ return useMemo(() => ({comments: value, isPending, error}), [error, isPending, value])
60
79
  }
@@ -0,0 +1,259 @@
1
+ import {type StateSource} from '@sanity/sdk'
2
+ import {
3
+ type Comment,
4
+ type CommentsQueryOptions,
5
+ getCommentsQueryErrorState,
6
+ getCommentsQueryState,
7
+ resolveCommentsQuery,
8
+ } from '@sanity/sdk/collaboration'
9
+ import {act, render, screen} from '@testing-library/react'
10
+ import {Suspense} from 'react'
11
+ import {type Observable, Subject} from 'rxjs'
12
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
13
+
14
+ import {ResourceProvider} from '../../context/ResourceProvider'
15
+ import {ResourcesContext} from '../../context/ResourcesContext'
16
+ import {useCommentsQuery} from './useCommentsQuery'
17
+
18
+ vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
19
+ const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
20
+ return {
21
+ ...original,
22
+ getCommentsQueryState: vi.fn(),
23
+ getCommentsQueryErrorState: vi.fn(),
24
+ resolveCommentsQuery: vi.fn(),
25
+ }
26
+ })
27
+
28
+ function comment(id: string): Comment {
29
+ return {
30
+ id,
31
+ createdAt: '2026-01-01T00:00:00Z',
32
+ authorId: 'user-1',
33
+ message: [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hello'}]}],
34
+ threadId: 'thread-1',
35
+ status: 'open',
36
+ documentId: 'doc-1',
37
+ sourceDocumentId: 'doc-1',
38
+ documentType: 'author',
39
+ fieldPath: 'title',
40
+ reactions: [],
41
+ }
42
+ }
43
+
44
+ /** See the equivalent in `useCommentThreads.test.tsx` for why this is keyed. */
45
+ function mockSource(
46
+ getCurrent: (options: CommentsQueryOptions) => Comment[] | undefined,
47
+ changed$?: Subject<void>,
48
+ ) {
49
+ vi.mocked(getCommentsQueryState).mockImplementation(
50
+ (_instance, options) =>
51
+ ({
52
+ getCurrent: () => getCurrent(options),
53
+ subscribe: vi.fn((cb?: () => void) => {
54
+ const subscription = changed$?.subscribe(() => cb?.())
55
+ return () => subscription?.unsubscribe()
56
+ }),
57
+ get observable(): Observable<Comment[] | undefined> {
58
+ throw new Error('Not implemented')
59
+ },
60
+ }) as StateSource<Comment[] | undefined>,
61
+ )
62
+ mockErrorSource(() => undefined)
63
+ }
64
+
65
+ /** The companion source, live by default. Call again to override. */
66
+ function mockErrorSource(getCurrent: () => unknown, changed$?: Subject<void>) {
67
+ vi.mocked(getCommentsQueryErrorState).mockImplementation(
68
+ () =>
69
+ ({
70
+ getCurrent,
71
+ subscribe: vi.fn((cb?: () => void) => {
72
+ const subscription = changed$?.subscribe(() => cb?.())
73
+ return () => subscription?.unsubscribe()
74
+ }),
75
+ get observable(): Observable<unknown> {
76
+ throw new Error('Not implemented')
77
+ },
78
+ }) as StateSource<unknown>,
79
+ )
80
+ }
81
+
82
+ function Wrapper({children}: {children: React.ReactNode}) {
83
+ return (
84
+ <ResourceProvider projectId="p" dataset="d" fallback={<p>Loading…</p>}>
85
+ <ResourcesContext.Provider value={{other: {projectId: 'p2', dataset: 'd2'}}}>
86
+ <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
87
+ </ResourcesContext.Provider>
88
+ </ResourceProvider>
89
+ )
90
+ }
91
+
92
+ describe('useCommentsQuery', () => {
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} = useCommentsQuery({filter: 'status == "open"'})
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(resolveCommentsQuery).mockReturnValue(
119
+ new Promise<Comment[]>((resolve) => {
120
+ settle = () => resolve(loaded)
121
+ }),
122
+ )
123
+
124
+ function TestComponent() {
125
+ const {comments} = useCommentsQuery({filter: 'status == "open"'})
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 filter and its params through to the store', () => {
141
+ const loaded: Comment[] = []
142
+ mockSource(() => loaded)
143
+
144
+ function TestComponent() {
145
+ useCommentsQuery({filter: '_system.createdBy == $userId', params: {userId: 'user-1'}})
146
+ return null
147
+ }
148
+
149
+ render(<TestComponent />, {wrapper: Wrapper})
150
+
151
+ expect(getCommentsQueryState).toHaveBeenCalledWith(
152
+ expect.anything(),
153
+ expect.objectContaining({
154
+ filter: '_system.createdBy == $userId',
155
+ params: {userId: 'user-1'},
156
+ resource: {projectId: 'p', dataset: 'd'},
157
+ }),
158
+ )
159
+ })
160
+
161
+ it('resolves a named resource from context', () => {
162
+ const loaded: Comment[] = []
163
+ mockSource(() => loaded)
164
+
165
+ function TestComponent() {
166
+ useCommentsQuery({filter: 'status == "open"', resourceName: 'other'})
167
+ return null
168
+ }
169
+
170
+ render(<TestComponent />, {wrapper: Wrapper})
171
+
172
+ expect(getCommentsQueryState).toHaveBeenCalledWith(
173
+ expect.anything(),
174
+ expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
175
+ )
176
+ })
177
+
178
+ it('re-reads when a param changes rather than only when the filter does', () => {
179
+ // The filter is a template and the params are what it means, so keying on
180
+ // the filter alone would leave a user's list showing another user's
181
+ // comments.
182
+ const loaded: Comment[] = []
183
+ mockSource(() => loaded)
184
+
185
+ function TestComponent({userId}: {userId: string}) {
186
+ useCommentsQuery({filter: '_system.createdBy == $userId', params: {userId}})
187
+ return null
188
+ }
189
+
190
+ const {rerender} = render(<TestComponent userId="user-1" />, {wrapper: Wrapper})
191
+ rerender(<TestComponent userId="user-2" />)
192
+
193
+ expect(getCommentsQueryState).toHaveBeenCalledWith(
194
+ expect.anything(),
195
+ expect.objectContaining({params: {userId: 'user-2'}}),
196
+ )
197
+ })
198
+
199
+ it('keeps the previous list on screen while a different filter loads', async () => {
200
+ const open = [comment('a')]
201
+ const resolved = [comment('b'), comment('c')]
202
+ const byFilter: Record<string, Comment[] | undefined> = {'status == "open"': open}
203
+ mockSource((options) => byFilter[options.filter])
204
+
205
+ let settle: () => void = () => {}
206
+ vi.mocked(resolveCommentsQuery).mockReturnValue(
207
+ new Promise<Comment[]>((resolve) => {
208
+ settle = () => resolve(resolved)
209
+ }),
210
+ )
211
+
212
+ function TestComponent({filter}: {filter: string}) {
213
+ const {comments, isPending} = useCommentsQuery({filter})
214
+ return <div data-testid="out">{`${comments.length} ${isPending ? 'pending' : 'idle'}`}</div>
215
+ }
216
+
217
+ const {rerender} = render(<TestComponent filter='status == "open"' />, {wrapper: Wrapper})
218
+ expect(screen.getByTestId('out').textContent).toBe('1 idle')
219
+
220
+ await act(async () => {
221
+ rerender(<TestComponent filter='status == "resolved"' />)
222
+ })
223
+
224
+ expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
225
+ expect(screen.getByTestId('out').textContent).toBe('1 pending')
226
+
227
+ await act(async () => {
228
+ byFilter['status == "resolved"'] = resolved
229
+ settle()
230
+ })
231
+
232
+ expect(screen.getByTestId('out').textContent).toBe('2 idle')
233
+ })
234
+
235
+ it('reports a listener that failed after the comments had loaded', async () => {
236
+ const loaded = [comment('a')]
237
+ mockSource(() => loaded)
238
+ const failure: {current: unknown} = {current: undefined}
239
+ const changed$ = new Subject<void>()
240
+ mockErrorSource(() => failure.current, changed$)
241
+
242
+ function TestComponent() {
243
+ const {comments, error} = useCommentsQuery({filter: 'status == "open"'})
244
+ return (
245
+ <div data-testid="out">{`${comments.length} ${error instanceof Error ? error.message : 'live'}`}</div>
246
+ )
247
+ }
248
+
249
+ render(<TestComponent />, {wrapper: Wrapper})
250
+ expect(screen.getByTestId('out').textContent).toBe('1 live')
251
+
252
+ await act(async () => {
253
+ failure.current = new Error('connection failed')
254
+ changed$.next()
255
+ })
256
+
257
+ expect(screen.getByTestId('out').textContent).toBe('1 connection failed')
258
+ })
259
+ })