@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,10 +1,13 @@
1
1
  import {
2
+ addReaction,
2
3
  createComment,
3
4
  removeComment,
5
+ removeReaction,
4
6
  replyToComment,
5
7
  setCommentStatus,
6
8
  updateComment,
7
- } from '@sanity/sdk'
9
+ updateCommentAnchor,
10
+ } from '@sanity/sdk/collaboration'
8
11
  import {renderHook} from '@testing-library/react'
9
12
  import {beforeEach, describe, expect, it, vi} from 'vitest'
10
13
 
@@ -12,15 +15,18 @@ import {ResourceProvider} from '../../context/ResourceProvider'
12
15
  import {ResourcesContext} from '../../context/ResourcesContext'
13
16
  import {useCommentActions} from './useCommentActions'
14
17
 
15
- vi.mock('@sanity/sdk', async (importOriginal) => {
16
- const original = await importOriginal<typeof import('@sanity/sdk')>()
18
+ vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
19
+ const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
17
20
  return {
18
21
  ...original,
19
22
  createComment: vi.fn(),
20
23
  replyToComment: vi.fn(),
21
24
  updateComment: vi.fn(),
25
+ updateCommentAnchor: vi.fn(),
22
26
  setCommentStatus: vi.fn(),
23
27
  removeComment: vi.fn(),
28
+ addReaction: vi.fn(),
29
+ removeReaction: vi.fn(),
24
30
  }
25
31
  })
26
32
 
@@ -29,6 +35,14 @@ const HANDLE = {documentId: 'doc-1', documentType: 'author'}
29
35
  /** Creates name a field, since a comment with no path is refused. */
30
36
  const CREATE = {...HANDLE, fieldPath: 'name'}
31
37
 
38
+ const MESSAGE = [{_type: 'block', _key: 'b1', children: [{_type: 'span', text: 'hi'}]}]
39
+
40
+ const ANCHOR = {
41
+ type: 'portable-text',
42
+ start: {_key: 'b1', offset: 0},
43
+ end: {_key: 'b1', offset: 5},
44
+ } as const
45
+
32
46
  // Hoisted: an inline object here would be a new value on every render, and the
33
47
  // callbacks are memoised against it.
34
48
  const RESOURCES = {other: {projectId: 'p2', dataset: 'd2'}}
@@ -73,23 +87,29 @@ describe('useCommentActions', () => {
73
87
  it('forwards each action to the store', () => {
74
88
  const {result} = setup()
75
89
 
76
- result.current.createComment({...CREATE, message: null})
77
- result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: null})
78
- result.current.updateComment({commentId: 'c1', message: null})
90
+ result.current.createComment({...CREATE, message: MESSAGE})
91
+ result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: MESSAGE})
92
+ result.current.updateComment({commentId: 'c1', message: MESSAGE})
93
+ result.current.updateCommentAnchor({commentId: 'c1', anchor: ANCHOR})
79
94
  result.current.setCommentStatus({commentId: 'c1', status: 'resolved'})
80
95
  result.current.removeComment({commentId: 'c1'})
96
+ result.current.addReaction({commentId: 'c1', shortName: ':+1:'})
97
+ result.current.removeReaction({commentId: 'c1', shortName: ':+1:'})
81
98
 
82
99
  expect(createComment).toHaveBeenCalledOnce()
83
100
  expect(replyToComment).toHaveBeenCalledOnce()
84
101
  expect(updateComment).toHaveBeenCalledOnce()
102
+ expect(updateCommentAnchor).toHaveBeenCalledOnce()
85
103
  expect(setCommentStatus).toHaveBeenCalledOnce()
86
104
  expect(removeComment).toHaveBeenCalledOnce()
105
+ expect(addReaction).toHaveBeenCalledOnce()
106
+ expect(removeReaction).toHaveBeenCalledOnce()
87
107
  })
88
108
 
89
109
  it('fills in the resource from context', () => {
90
110
  const {result} = setup()
91
111
 
92
- result.current.createComment({...CREATE, message: null})
112
+ result.current.createComment({...CREATE, message: MESSAGE})
93
113
 
94
114
  expect(createComment).toHaveBeenCalledWith(
95
115
  expect.anything(),
@@ -119,13 +139,13 @@ describe('useCommentActions', () => {
119
139
  })
120
140
 
121
141
  it('fills in the perspective from context', () => {
122
- // Core turns a release perspective into `target.documentVersionId`, so
123
- // losing it here files the comment against the wrong release and nothing
124
- // reports an error.
142
+ // Core turns a release perspective into the source document id the comment
143
+ // is written against, so losing it here files the comment against the wrong
144
+ // release and nothing reports an error.
125
145
  const {result} = setup(PerspectiveWrapper)
126
146
 
127
- result.current.createComment({...CREATE, message: null})
128
- result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: null})
147
+ result.current.createComment({...CREATE, message: MESSAGE})
148
+ result.current.replyToComment({...HANDLE, parentCommentId: 'p1', message: MESSAGE})
129
149
 
130
150
  expect(createComment).toHaveBeenCalledWith(
131
151
  expect.anything(),
@@ -140,7 +160,7 @@ describe('useCommentActions', () => {
140
160
  it('lets a call override the perspective from context', () => {
141
161
  const {result} = setup(PerspectiveWrapper)
142
162
 
143
- result.current.createComment({...CREATE, message: null, perspective: 'published'})
163
+ result.current.createComment({...CREATE, message: MESSAGE, perspective: 'published'})
144
164
 
145
165
  expect(createComment).toHaveBeenCalledWith(
146
166
  expect.anything(),
@@ -1,18 +1,22 @@
1
+ import {type DocumentResource, type PerspectiveHandle} from '@sanity/sdk'
1
2
  import {
3
+ addReaction,
2
4
  type Comment,
3
5
  createComment,
4
6
  type CreateCommentOptions,
5
- type DocumentResource,
6
- type PerspectiveHandle,
7
+ type ReactionOptions,
7
8
  removeComment,
8
9
  type RemoveCommentOptions,
10
+ removeReaction,
9
11
  replyToComment,
10
12
  type ReplyToCommentOptions,
11
13
  setCommentStatus,
12
14
  type SetCommentStatusOptions,
13
15
  updateComment,
16
+ updateCommentAnchor,
17
+ type UpdateCommentAnchorOptions,
14
18
  type UpdateCommentOptions,
15
- } from '@sanity/sdk'
19
+ } from '@sanity/sdk/collaboration'
16
20
  import {useContext, useMemo} from 'react'
17
21
 
18
22
  import {PerspectiveContext} from '../../context/PerspectiveContext'
@@ -26,21 +30,31 @@ import {
26
30
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
27
31
 
28
32
  /**
29
- * @public
33
+ * @beta
30
34
  * @category Types
31
35
  * @category Comments
32
36
  */
33
37
  export interface CommentActions {
34
- /** Starts a thread on a document, or on one of its fields. */
38
+ /** Starts a thread on one of a document's fields. */
35
39
  createComment: (options: WithResourceNameSupport<CreateCommentOptions>) => Promise<Comment>
36
40
  /** Adds a reply to an existing thread. */
37
41
  replyToComment: (options: WithResourceNameSupport<ReplyToCommentOptions>) => Promise<Comment>
38
42
  /** Rewrites a comment's message. */
39
43
  updateComment: (options: WithResourceNameSupport<UpdateCommentOptions>) => Promise<void>
44
+ /**
45
+ * Re-anchors a comment to a different run of text, without marking it edited.
46
+ */
47
+ updateCommentAnchor: (
48
+ options: WithResourceNameSupport<UpdateCommentAnchorOptions>,
49
+ ) => Promise<void>
40
50
  /** Resolves or reopens a thread. Pass the thread's first comment. */
41
51
  setCommentStatus: (options: WithResourceNameSupport<SetCommentStatusOptions>) => Promise<void>
42
52
  /** Deletes a comment, and its replies when it starts a thread. */
43
53
  removeComment: (options: WithResourceNameSupport<RemoveCommentOptions>) => Promise<void>
54
+ /** Adds the current user's reaction to a comment. */
55
+ addReaction: (options: WithResourceNameSupport<ReactionOptions>) => Promise<void>
56
+ /** Removes the current user's reaction from a comment. */
57
+ removeReaction: (options: WithResourceNameSupport<ReactionOptions>) => Promise<void>
44
58
  }
45
59
 
46
60
  type Resolvable = {
@@ -52,25 +66,28 @@ type Resolvable = {
52
66
  }
53
67
 
54
68
  /**
55
- * Returns the five comment write actions, bound to the current instance.
69
+ * Returns the comment write actions, bound to the current instance.
56
70
  *
57
71
  * Each writes optimistically: the change shows immediately and is rolled back
58
72
  * if the server rejects it, so an app can render straight from
59
- * {@link useComments} without tracking pending state itself.
73
+ * {@link useCommentThreads} without tracking pending state itself.
60
74
  *
61
75
  * Creating is the exception, and `replyToComment` counts as creating. A comment
62
- * that fails to post stays on screen carrying `_state.createError` rather than
76
+ * that fails to post stays on screen carrying `state.createError` rather than
63
77
  * disappearing, so passing the same `commentId` again retries it instead of
64
78
  * losing what someone typed.
65
79
  *
66
80
  * The resource is resolved when an action is called rather than when the hook
67
81
  * runs, so one set of callbacks works across several resources. Pass `resource`
68
82
  * or `resourceName` per call to choose; otherwise the surrounding
69
- * `ResourceProvider` decides.
83
+ * `ResourceProvider` decides. The organization the comments live in is
84
+ * resolved per call as well: the one the Dashboard opened the app in, else
85
+ * `organizationId` on the Sanity config, else the organization owning the
86
+ * resource's project. It cannot be passed on the call.
70
87
  *
71
88
  * @category Comments
72
89
  * @function
73
- * @returns The five write actions
90
+ * @returns The write actions
74
91
  *
75
92
  * @example Resolve a thread
76
93
  * ```tsx
@@ -85,7 +102,7 @@ type Resolvable = {
85
102
  * }
86
103
  * ```
87
104
  *
88
- * @public
105
+ * @beta
89
106
  */
90
107
  export function useCommentActions(): CommentActions {
91
108
  const instance = useSanityInstance()
@@ -103,8 +120,11 @@ export function useCommentActions(): CommentActions {
103
120
  createComment: (options) => createComment(instance, resolve(options)),
104
121
  replyToComment: (options) => replyToComment(instance, resolve(options)),
105
122
  updateComment: (options) => updateComment(instance, resolve(options)),
123
+ updateCommentAnchor: (options) => updateCommentAnchor(instance, resolve(options)),
106
124
  setCommentStatus: (options) => setCommentStatus(instance, resolve(options)),
107
125
  removeComment: (options) => removeComment(instance, resolve(options)),
126
+ addReaction: (options) => addReaction(instance, resolve(options)),
127
+ removeReaction: (options) => removeReaction(instance, resolve(options)),
108
128
  }
109
129
  }, [contextPerspective, contextResource, instance, resources])
110
130
  }
@@ -1,5 +1,4 @@
1
- import {type CommentsOptions, type SanityInstance, type StateSource} from '@sanity/sdk'
2
- import {getCommentsOptionsKey, parseCommentsOptionsKey} from '@sanity/sdk/_internal'
1
+ import {type DocumentResource, type SanityInstance, type StateSource} from '@sanity/sdk'
3
2
  import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react'
4
3
 
5
4
  import {useSanityInstance} from '../context/useSanityInstance'
@@ -9,17 +8,22 @@ import {
9
8
  } from '../helpers/useNormalizedResourceOptions'
10
9
  import {trackHookUsage} from '../helpers/useTrackHookUsage'
11
10
 
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>
11
+ /**
12
+ * The core functions backing one read hook, plus the option-key pair that keeps
13
+ * its state source steady across renders.
14
+ *
15
+ * @internal
16
+ */
17
+ export interface CommentListSource<TOptions, TValue> {
18
+ getState: (instance: SanityInstance, options: TOptions) => StateSource<TValue | undefined>
19
+ getErrorState: (instance: SanityInstance, options: TOptions) => StateSource<unknown>
20
+ resolve: (instance: SanityInstance, options: TOptions & {signal?: AbortSignal}) => Promise<TValue>
21
+ getKey: (options: TOptions) => string
22
+ parseKey: (key: string) => TOptions
19
23
  }
20
24
 
21
25
  /**
22
- * Shared body of {@link useComments} and {@link useCommentThreads}.
26
+ * Shared body of the comment read hooks.
23
27
  *
24
28
  * Suspends until the first snapshot arrives. Changing documents or filters
25
29
  * happens in a transition, so the list already on screen stays put and
@@ -27,20 +31,23 @@ export interface CommentListSource<T> {
27
31
  * previous read is aborted, which drops its listener when nothing else is
28
32
  * reading it.
29
33
  *
34
+ * A listener that fails after the list has loaded is reported through `error`
35
+ * rather than thrown: the list stays on screen, frozen as it last stood.
36
+ *
30
37
  * @internal
31
38
  */
32
- export function useCommentList<T>(
39
+ export function useCommentList<TOptions extends {resource?: DocumentResource}, TValue>(
33
40
  hookName: string,
34
- options: WithResourceNameSupport<CommentsOptions>,
35
- {getState, resolve}: CommentListSource<T>,
36
- ): {value: T; isPending: boolean} {
41
+ options: WithResourceNameSupport<TOptions>,
42
+ {getState, getErrorState, resolve, getKey, parseKey}: CommentListSource<TOptions, TValue>,
43
+ ): {value: TValue; isPending: boolean; error: unknown} {
37
44
  const instance = useSanityInstance()
38
45
  trackHookUsage(instance, hookName)
39
46
 
40
- const normalized = useNormalizedResourceOptions(options)
47
+ const normalized = useNormalizedResourceOptions(options) as TOptions
41
48
  const [isPending, startTransition] = useTransition()
42
49
 
43
- const key = getCommentsOptionsKey(normalized)
50
+ const key = getKey(normalized)
44
51
  // Held one render behind `key`, so the swap can happen inside a transition.
45
52
  const [deferredKey, setDeferredKey] = useState(key)
46
53
  const abortRef = useRef<AbortController>(new AbortController())
@@ -57,11 +64,15 @@ export function useCommentList<T>(
57
64
  })
58
65
  }, [deferredKey, key])
59
66
 
60
- const deferred = useMemo(() => parseCommentsOptionsKey(deferredKey), [deferredKey])
67
+ const deferred = useMemo(() => parseKey(deferredKey), [deferredKey, parseKey])
61
68
  const {getCurrent, subscribe} = useMemo(
62
69
  () => getState(instance, deferred),
63
70
  [deferred, getState, instance],
64
71
  )
72
+ const errorSource = useMemo(
73
+ () => getErrorState(instance, deferred),
74
+ [deferred, getErrorState, instance],
75
+ )
65
76
 
66
77
  if (getCurrent() === undefined) {
67
78
  // Reading the ref mid-render is safe here: React runs no effects for a
@@ -73,7 +84,11 @@ export function useCommentList<T>(
73
84
  throw resolve(instance, {...deferred, signal: currentSignal})
74
85
  }
75
86
 
76
- // Not memoised: both callers destructure this immediately and memoise their
87
+ // Not memoised: every caller destructures this immediately and memoises its
77
88
  // own result object, so a stable identity here would never be observed.
78
- return {value: useSyncExternalStore(subscribe, getCurrent) as T, isPending}
89
+ return {
90
+ value: useSyncExternalStore(subscribe, getCurrent) as TValue,
91
+ isPending,
92
+ error: useSyncExternalStore(errorSource.subscribe, errorSource.getCurrent),
93
+ }
79
94
  }
@@ -1,20 +1,28 @@
1
+ import {type StateSource} from '@sanity/sdk'
1
2
  import {
3
+ type CommentsOptions,
2
4
  type CommentThread,
5
+ getCommentsErrorState,
3
6
  getCommentThreadsState,
4
7
  resolveCommentThreads,
5
- type StateSource,
6
- } from '@sanity/sdk'
8
+ } from '@sanity/sdk/collaboration'
7
9
  import {act, render, screen} from '@testing-library/react'
8
10
  import {Suspense} from 'react'
9
- import {type Observable} from 'rxjs'
11
+ import {type Observable, Subject} from 'rxjs'
10
12
  import {beforeEach, describe, expect, it, vi} from 'vitest'
11
13
 
12
14
  import {ResourceProvider} from '../../context/ResourceProvider'
15
+ import {ResourcesContext} from '../../context/ResourcesContext'
13
16
  import {useCommentThreads} from './useCommentThreads'
14
17
 
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
+ vi.mock('@sanity/sdk/collaboration', async (importOriginal) => {
19
+ const original = await importOriginal<typeof import('@sanity/sdk/collaboration')>()
20
+ return {
21
+ ...original,
22
+ getCommentThreadsState: vi.fn(),
23
+ getCommentsErrorState: vi.fn(),
24
+ resolveCommentThreads: vi.fn(),
25
+ }
18
26
  })
19
27
 
20
28
  const HANDLE = {documentId: 'doc-1', documentType: 'author'}
@@ -23,19 +31,72 @@ function thread(threadId: string) {
23
31
  return {threadId, commentsCount: 2, fieldPath: ''} as CommentThread
24
32
  }
25
33
 
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
+ * Stands in for the store, one source per option set so a test can hold one
36
+ * document loaded and another not.
37
+ *
38
+ * `getCurrent` must hand back the same array every call for a given option set.
39
+ * Returning a fresh one sends `useSyncExternalStore` into a render loop, which
40
+ * is why the store memoises its selectors.
41
+ */
42
+ function mockSource(
43
+ getCurrent: (options: CommentsOptions) => CommentThread[] | undefined,
44
+ changed$?: Subject<void>,
45
+ ) {
46
+ vi.mocked(getCommentThreadsState).mockImplementation(
47
+ (_instance, options) =>
48
+ ({
49
+ getCurrent: () => getCurrent(options),
50
+ subscribe: vi.fn((cb?: () => void) => {
51
+ const subscription = changed$?.subscribe(() => cb?.())
52
+ return () => subscription?.unsubscribe()
53
+ }),
54
+ get observable(): Observable<CommentThread[] | undefined> {
55
+ throw new Error('Not implemented')
56
+ },
57
+ }) as StateSource<CommentThread[] | undefined>,
58
+ )
59
+ mockErrorSource(() => undefined)
34
60
  }
35
61
 
62
+ /** The companion source, live by default. Call again to override. */
63
+ function mockErrorSource(getCurrent: () => unknown, changed$?: Subject<void>) {
64
+ vi.mocked(getCommentsErrorState).mockImplementation(
65
+ () =>
66
+ ({
67
+ getCurrent,
68
+ subscribe: vi.fn((cb?: () => void) => {
69
+ const subscription = changed$?.subscribe(() => cb?.())
70
+ return () => subscription?.unsubscribe()
71
+ }),
72
+ get observable(): Observable<unknown> {
73
+ throw new Error('Not implemented')
74
+ },
75
+ }) as StateSource<unknown>,
76
+ )
77
+ }
78
+
79
+ /** Hoisted so the context value stays identical across renders. */
80
+ const RELEASE_PERSPECTIVE = {releaseName: 'summer'}
81
+
36
82
  function Wrapper({children}: {children: React.ReactNode}) {
37
83
  return (
38
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
+ function PerspectiveWrapper({children}: {children: React.ReactNode}) {
93
+ return (
94
+ <ResourceProvider
95
+ projectId="p"
96
+ dataset="d"
97
+ perspective={RELEASE_PERSPECTIVE}
98
+ fallback={<p>Loading…</p>}
99
+ >
39
100
  <Suspense fallback={<p data-testid="suspended">Suspended</p>}>{children}</Suspense>
40
101
  </ResourceProvider>
41
102
  )
@@ -60,10 +121,11 @@ describe('useCommentThreads', () => {
60
121
  expect(screen.getByTestId('out').textContent).toBe('2 idle')
61
122
  })
62
123
 
63
- it('suspends until the threads are available', async () => {
124
+ it('suspends until the first snapshot arrives', async () => {
64
125
  const loaded = [thread('t1')]
65
126
  const ref: {current: CommentThread[] | undefined} = {current: undefined}
66
- mockSource(() => ref.current)
127
+ const changed$ = new Subject<void>()
128
+ mockSource(() => ref.current, changed$)
67
129
 
68
130
  let settle: () => void = () => {}
69
131
  vi.mocked(resolveCommentThreads).mockReturnValue(
@@ -88,12 +150,40 @@ describe('useCommentThreads', () => {
88
150
  expect(screen.getByTestId('out').textContent).toBe('1')
89
151
  })
90
152
 
91
- it('narrows to one field when asked', () => {
153
+ it('passes the field path, status, and scope through to the store', () => {
154
+ const loaded: CommentThread[] = []
155
+ mockSource(() => loaded)
156
+
157
+ function TestComponent() {
158
+ useCommentThreads({
159
+ ...HANDLE,
160
+ fieldPath: ['body', {_key: 'intro'}],
161
+ status: 'resolved',
162
+ scope: 'all',
163
+ })
164
+ return null
165
+ }
166
+
167
+ render(<TestComponent />, {wrapper: Wrapper})
168
+
169
+ expect(getCommentThreadsState).toHaveBeenCalledWith(
170
+ expect.anything(),
171
+ expect.objectContaining({
172
+ documentId: 'doc-1',
173
+ fieldPath: 'body[_key=="intro"]',
174
+ status: 'resolved',
175
+ scope: 'all',
176
+ resource: {projectId: 'p', dataset: 'd'},
177
+ }),
178
+ )
179
+ })
180
+
181
+ it('resolves a named resource from context', () => {
92
182
  const loaded: CommentThread[] = []
93
183
  mockSource(() => loaded)
94
184
 
95
185
  function TestComponent() {
96
- useCommentThreads({...HANDLE, fieldPath: 'title'})
186
+ useCommentThreads({...HANDLE, resourceName: 'other'})
97
187
  return null
98
188
  }
99
189
 
@@ -101,7 +191,105 @@ describe('useCommentThreads', () => {
101
191
 
102
192
  expect(getCommentThreadsState).toHaveBeenCalledWith(
103
193
  expect.anything(),
104
- expect.objectContaining({fieldPath: 'title'}),
194
+ expect.objectContaining({resource: {projectId: 'p2', dataset: 'd2'}}),
195
+ )
196
+ })
197
+
198
+ it('fills in the perspective from context', () => {
199
+ // The perspective decides which comments the default `scope` covers, so
200
+ // dropping it here would quietly read a release's comments as if they were
201
+ // the draft's.
202
+ const loaded: CommentThread[] = []
203
+ mockSource(() => loaded)
204
+
205
+ function TestComponent() {
206
+ useCommentThreads(HANDLE)
207
+ return null
208
+ }
209
+
210
+ render(<TestComponent />, {wrapper: PerspectiveWrapper})
211
+
212
+ expect(getCommentThreadsState).toHaveBeenCalledWith(
213
+ expect.anything(),
214
+ expect.objectContaining({perspective: RELEASE_PERSPECTIVE}),
215
+ )
216
+ })
217
+
218
+ it('keeps the previous list on screen while a different document loads', async () => {
219
+ const first = [thread('t1')]
220
+ const second = [thread('t2'), thread('t3')]
221
+ // Only `doc-1` is loaded, so switching to `doc-2` has to suspend.
222
+ const byDocument: Record<string, CommentThread[] | undefined> = {'doc-1': first}
223
+ mockSource((options) => byDocument[options.documentId])
224
+
225
+ let settle: () => void = () => {}
226
+ vi.mocked(resolveCommentThreads).mockReturnValue(
227
+ new Promise<CommentThread[]>((resolve) => {
228
+ settle = () => resolve(second)
229
+ }),
230
+ )
231
+
232
+ function TestComponent({documentId}: {documentId: string}) {
233
+ const {threads, isPending} = useCommentThreads({...HANDLE, documentId})
234
+ return <div data-testid="out">{`${threads.length} ${isPending ? 'pending' : 'idle'}`}</div>
235
+ }
236
+
237
+ const {rerender} = render(<TestComponent documentId="doc-1" />, {wrapper: Wrapper})
238
+ expect(screen.getByTestId('out').textContent).toBe('1 idle')
239
+
240
+ await act(async () => {
241
+ rerender(<TestComponent documentId="doc-2" />)
242
+ })
243
+
244
+ // The swap happens inside a transition, so the render that suspends is
245
+ // thrown away rather than falling back: `doc-1` stays on screen and
246
+ // `isPending` is what reports the switch.
247
+ expect(screen.queryByTestId('suspended')).not.toBeInTheDocument()
248
+ expect(screen.getByTestId('out').textContent).toBe('1 pending')
249
+ expect(getCommentThreadsState).toHaveBeenCalledWith(
250
+ expect.anything(),
251
+ expect.objectContaining({documentId: 'doc-2'}),
105
252
  )
253
+
254
+ await act(async () => {
255
+ byDocument['doc-2'] = second
256
+ settle()
257
+ })
258
+
259
+ expect(screen.getByTestId('out').textContent).toBe('2 idle')
260
+ })
261
+
262
+ it('reports a listener that failed after the threads had loaded', async () => {
263
+ const loaded = [thread('t1')]
264
+ mockSource(() => loaded)
265
+ const failure: {current: unknown} = {current: undefined}
266
+ const changed$ = new Subject<void>()
267
+ mockErrorSource(() => failure.current, changed$)
268
+
269
+ function TestComponent() {
270
+ const {threads, error} = useCommentThreads(HANDLE)
271
+ return (
272
+ <div data-testid="out">{`${threads.length} ${error instanceof Error ? error.message : 'live'}`}</div>
273
+ )
274
+ }
275
+
276
+ render(<TestComponent />, {wrapper: Wrapper})
277
+ expect(screen.getByTestId('out').textContent).toBe('1 live')
278
+
279
+ await act(async () => {
280
+ failure.current = new Error('connection failed')
281
+ changed$.next()
282
+ })
283
+
284
+ // The threads are still on screen; only their claim to being current is
285
+ // withdrawn.
286
+ expect(screen.getByTestId('out').textContent).toBe('1 connection failed')
287
+
288
+ await act(async () => {
289
+ failure.current = undefined
290
+ changed$.next()
291
+ })
292
+
293
+ expect(screen.getByTestId('out').textContent).toBe('1 live')
106
294
  })
107
295
  })