@sanity/sdk 2.18.0 → 2.20.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.
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +196 -22
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/authStore-CWTAuieY.js +1072 -0
- package/dist/authStore-CWTAuieY.js.map +1 -0
- package/dist/clientStore-CMzJ7XhZ.js +168 -0
- package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
- package/dist/getEnv-76AjZd_f.js +178 -0
- package/dist/getEnv-76AjZd_f.js.map +1 -0
- package/dist/index.d.ts +2 -1921
- package/dist/index.js +4165 -3711
- package/dist/index.js.map +1 -1
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/telemetryManager-Bd3O8z2S.js +127 -0
- package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
- package/dist/utils-BMwA8Kg9.d.ts +3334 -0
- package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
- package/dist/version-DL2l07HG.js +9 -0
- package/dist/version-DL2l07HG.js.map +1 -0
- package/package.json +13 -21
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +37 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/document/documentConstants.ts +8 -0
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/document/documentStore.test.ts +98 -1
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +133 -0
- package/src/document/reducers.ts +18 -3
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/projection/projectionStore.test.ts +0 -8
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -133
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {type ListenOptions} from '@sanity/client'
|
|
2
|
+
|
|
3
|
+
/** Data API version used for reading and writing comments in the addon dataset. */
|
|
4
|
+
export const COMMENTS_API_VERSION = 'v2025-05-06'
|
|
5
|
+
|
|
6
|
+
/** Projects API version used to discover and provision the addon dataset. */
|
|
7
|
+
export const ADDON_DATASET_API_VERSION = 'v2025-02-19'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The dataset profile the Studio tags its comments dataset with. Discovery
|
|
11
|
+
* matches on it, so changing it stops the SDK and the Studio sharing comments.
|
|
12
|
+
*/
|
|
13
|
+
export const ADDON_DATASET_PROFILE = 'comments'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How long a comment list outlives its last subscriber before being dropped.
|
|
17
|
+
*
|
|
18
|
+
* Long enough that navigating away from a document and straight back reuses the
|
|
19
|
+
* loaded list instead of refetching and re-suspending.
|
|
20
|
+
*/
|
|
21
|
+
export const COMMENTS_STATE_CLEAR_DELAY = 5000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Kept identical to the Studio's own listener options
|
|
25
|
+
* (`packages/sanity/src/core/comments/store/useCommentsStore.ts:29-35`), apart
|
|
26
|
+
* from the request tag. `includeResult` is what lets a mutation event carry the
|
|
27
|
+
* changed comment, which is what makes per-comment reconciliation possible
|
|
28
|
+
* rather than refetching the list.
|
|
29
|
+
*/
|
|
30
|
+
export const LISTEN_OPTIONS: ListenOptions = {
|
|
31
|
+
events: ['welcome', 'mutation', 'reconnect'],
|
|
32
|
+
includeResult: true,
|
|
33
|
+
includeAllVersions: true,
|
|
34
|
+
visibility: 'query',
|
|
35
|
+
tag: 'comments.listen',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const QUERY_PROJECTION = `{
|
|
39
|
+
_createdAt,
|
|
40
|
+
_id,
|
|
41
|
+
_rev,
|
|
42
|
+
authorId,
|
|
43
|
+
contentSnapshot,
|
|
44
|
+
context,
|
|
45
|
+
lastEditedAt,
|
|
46
|
+
message,
|
|
47
|
+
parentCommentId,
|
|
48
|
+
reactions,
|
|
49
|
+
status,
|
|
50
|
+
target,
|
|
51
|
+
threadId
|
|
52
|
+
}`
|
|
53
|
+
|
|
54
|
+
const BASE_FILTERS = [`_type == "comment"`, `target.document._ref == $documentId`]
|
|
55
|
+
const VERSION_FILTER = `target.documentVersionId == $documentVersionId`
|
|
56
|
+
const NO_VERSION_FILTER = `!defined(target.documentVersionId)`
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Which comments belong to a document, matching the Studio's filter.
|
|
60
|
+
*
|
|
61
|
+
* A release's comments are kept apart from the default ones: asking for a
|
|
62
|
+
* release returns only that release's comments, and asking without one returns
|
|
63
|
+
* only comments with no version at all.
|
|
64
|
+
*/
|
|
65
|
+
function buildCommentsFilter(documentVersionId?: string): string {
|
|
66
|
+
return [...BASE_FILTERS, documentVersionId ? VERSION_FILTER : NO_VERSION_FILTER].join(' && ')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The snapshot query. Newest comment first, as the Studio orders it. */
|
|
70
|
+
export function buildCommentsQuery(documentVersionId?: string): string {
|
|
71
|
+
return `*[${buildCommentsFilter(documentVersionId)}] ${QUERY_PROJECTION} | order(_createdAt desc)`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The listener query. Unprojected on purpose: mutation events carry the whole
|
|
76
|
+
* comment, so there is nothing to trim.
|
|
77
|
+
*/
|
|
78
|
+
export function buildCommentsListenQuery(documentVersionId?: string): string {
|
|
79
|
+
return `*[${buildCommentsFilter(documentVersionId)}]`
|
|
80
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {type ListenEvent, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {BehaviorSubject, Subject} from 'rxjs'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {type DocumentResource} from '../config/sanityConfig'
|
|
6
|
+
import {bindActionByResource} from '../store/createActionBinder'
|
|
7
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
8
|
+
import {observeAddonDatasetClient} from './addonDatasetStore'
|
|
9
|
+
import {
|
|
10
|
+
commentsStore,
|
|
11
|
+
getCommentsOptionsKey,
|
|
12
|
+
getCommentsState,
|
|
13
|
+
getCommentThreadsState,
|
|
14
|
+
parseCommentsOptionsKey,
|
|
15
|
+
resolveComments,
|
|
16
|
+
} from './commentsStore'
|
|
17
|
+
import {setPendingTransaction} from './reducers'
|
|
18
|
+
import {type StoredComment} from './types'
|
|
19
|
+
|
|
20
|
+
vi.mock('./addonDatasetStore', () => ({
|
|
21
|
+
observeAddonDatasetClient: vi.fn(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
function comment(overrides: Partial<StoredComment> & Pick<StoredComment, '_id'>) {
|
|
25
|
+
return {
|
|
26
|
+
_type: 'comment',
|
|
27
|
+
_createdAt: '2026-01-01T00:00:00Z',
|
|
28
|
+
_rev: 'rev',
|
|
29
|
+
authorId: 'user-1',
|
|
30
|
+
message: null,
|
|
31
|
+
threadId: 'thread-1',
|
|
32
|
+
status: 'open',
|
|
33
|
+
reactions: null,
|
|
34
|
+
target: {documentType: 'author', document: {_ref: 'doc-1', _type: 'reference', _weak: true}},
|
|
35
|
+
...overrides,
|
|
36
|
+
} satisfies StoredComment as StoredComment
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const WELCOME = {type: 'welcome'} as ListenEvent<StoredComment>
|
|
40
|
+
|
|
41
|
+
/** Lets a test seed a pending transaction the way the write actions will. */
|
|
42
|
+
const markPending = bindActionByResource(
|
|
43
|
+
commentsStore,
|
|
44
|
+
({state}, options: {resource?: DocumentResource; commentId: string; transactionId: string}) =>
|
|
45
|
+
state.set(
|
|
46
|
+
'setPendingTransaction',
|
|
47
|
+
setPendingTransaction(options.commentId, options.transactionId),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const HANDLE = {documentId: 'doc-1', documentType: 'author'}
|
|
52
|
+
|
|
53
|
+
let instance: SanityInstance
|
|
54
|
+
let listeners: Map<string, Subject<ListenEvent<StoredComment>>>
|
|
55
|
+
let fetches: Subject<StoredComment[]>[]
|
|
56
|
+
let client$: BehaviorSubject<SanityClient | null>
|
|
57
|
+
let client: SanityClient
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Keyed on query *and* params: the document id travels as a parameter, so two
|
|
61
|
+
* documents share one query string but must not share a listener.
|
|
62
|
+
*/
|
|
63
|
+
function listenerFor(query: string, params: Record<string, unknown>) {
|
|
64
|
+
const key = `${query}|${JSON.stringify(params)}`
|
|
65
|
+
const existing = listeners.get(key)
|
|
66
|
+
if (existing) return existing
|
|
67
|
+
const subject = new Subject<ListenEvent<StoredComment>>()
|
|
68
|
+
listeners.set(key, subject)
|
|
69
|
+
return subject
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.resetAllMocks()
|
|
74
|
+
listeners = new Map()
|
|
75
|
+
fetches = []
|
|
76
|
+
|
|
77
|
+
client = {
|
|
78
|
+
observable: {
|
|
79
|
+
listen: vi.fn((query: string, params: Record<string, unknown>) => listenerFor(query, params)),
|
|
80
|
+
fetch: vi.fn(() => {
|
|
81
|
+
const fetch$ = new Subject<StoredComment[]>()
|
|
82
|
+
fetches.push(fetch$)
|
|
83
|
+
return fetch$
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
} as unknown as SanityClient
|
|
87
|
+
|
|
88
|
+
client$ = new BehaviorSubject<SanityClient | null>(client)
|
|
89
|
+
vi.mocked(observeAddonDatasetClient).mockReturnValue(client$)
|
|
90
|
+
|
|
91
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
instance.dispose()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('getCommentsOptionsKey', () => {
|
|
99
|
+
it('round-trips the options it is given', () => {
|
|
100
|
+
const options = {
|
|
101
|
+
...HANDLE,
|
|
102
|
+
perspective: {releaseName: 'summer'},
|
|
103
|
+
fieldPath: 'title',
|
|
104
|
+
status: 'resolved' as const,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
expect(parseCommentsOptionsKey(getCommentsOptionsKey(options))).toEqual(options)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('gives a path array and its string form the same key', () => {
|
|
111
|
+
expect(
|
|
112
|
+
getCommentsOptionsKey({...HANDLE, fieldPath: ['body', {_key: 'intro'}, 'content']}),
|
|
113
|
+
).toBe(getCommentsOptionsKey({...HANDLE, fieldPath: 'body[_key=="intro"].content'}))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('separates lists that differ only by filter', () => {
|
|
117
|
+
const keys = new Set([
|
|
118
|
+
getCommentsOptionsKey(HANDLE),
|
|
119
|
+
getCommentsOptionsKey({...HANDLE, fieldPath: ''}),
|
|
120
|
+
getCommentsOptionsKey({...HANDLE, fieldPath: 'title'}),
|
|
121
|
+
getCommentsOptionsKey({...HANDLE, status: 'open'}),
|
|
122
|
+
getCommentsOptionsKey({...HANDLE, documentId: 'doc-2'}),
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
expect(keys.size).toBe(5)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('getCommentsState', () => {
|
|
130
|
+
it('is undefined until the first snapshot arrives', () => {
|
|
131
|
+
const source = getCommentsState(instance, HANDLE)
|
|
132
|
+
source.subscribe()
|
|
133
|
+
|
|
134
|
+
expect(source.getCurrent()).toBe(undefined)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('loads a document’s comments once someone reads them', () => {
|
|
138
|
+
const source = getCommentsState(instance, HANDLE)
|
|
139
|
+
source.subscribe()
|
|
140
|
+
|
|
141
|
+
listeners.values().next().value!.next(WELCOME)
|
|
142
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
143
|
+
|
|
144
|
+
// Asserted in full rather than by id. This is the entire mapping from the
|
|
145
|
+
// stored document to what a consumer sees, and it is what has to stay put
|
|
146
|
+
// when comments move off the addon dataset.
|
|
147
|
+
expect(source.getCurrent()).toEqual([
|
|
148
|
+
{
|
|
149
|
+
id: 'a',
|
|
150
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
151
|
+
authorId: 'user-1',
|
|
152
|
+
message: null,
|
|
153
|
+
threadId: 'thread-1',
|
|
154
|
+
status: 'open',
|
|
155
|
+
documentId: 'doc-1',
|
|
156
|
+
documentType: 'author',
|
|
157
|
+
fieldPath: '',
|
|
158
|
+
reactions: [],
|
|
159
|
+
},
|
|
160
|
+
])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('sorts newest first', () => {
|
|
164
|
+
const source = getCommentsState(instance, HANDLE)
|
|
165
|
+
source.subscribe()
|
|
166
|
+
|
|
167
|
+
const older = comment({_id: 'a', _createdAt: '2026-01-01T00:00:00Z'})
|
|
168
|
+
const newer = comment({_id: 'b', _createdAt: '2026-02-01T00:00:00Z'})
|
|
169
|
+
|
|
170
|
+
listeners.values().next().value!.next(WELCOME)
|
|
171
|
+
fetches[0].next([older, newer])
|
|
172
|
+
|
|
173
|
+
expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b', 'a'])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('settles on an empty list when the project has no comments dataset', () => {
|
|
177
|
+
// Leaving this unset would suspend readers forever on a project nobody has
|
|
178
|
+
// ever commented in.
|
|
179
|
+
client$.next(null)
|
|
180
|
+
|
|
181
|
+
const source = getCommentsState(instance, HANDLE)
|
|
182
|
+
source.subscribe()
|
|
183
|
+
|
|
184
|
+
expect(source.getCurrent()).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('filters by field path', () => {
|
|
188
|
+
const source = getCommentsState(instance, {...HANDLE, fieldPath: 'title'})
|
|
189
|
+
source.subscribe()
|
|
190
|
+
|
|
191
|
+
const onField = comment({
|
|
192
|
+
_id: 'a',
|
|
193
|
+
target: {
|
|
194
|
+
documentType: 'author',
|
|
195
|
+
path: {field: 'title'},
|
|
196
|
+
document: {_ref: 'doc-1', _type: 'reference', _weak: true},
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
listeners.values().next().value!.next(WELCOME)
|
|
201
|
+
fetches[0].next([onField, comment({_id: 'b'})])
|
|
202
|
+
|
|
203
|
+
expect(source.getCurrent()!.map((c) => c.id)).toEqual(['a'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('matches the field path exactly rather than by prefix', () => {
|
|
207
|
+
// Nothing writes an empty path any more, but the filter is still an exact
|
|
208
|
+
// match, so it must not sweep up every comment on the document.
|
|
209
|
+
const source = getCommentsState(instance, {...HANDLE, fieldPath: ''})
|
|
210
|
+
source.subscribe()
|
|
211
|
+
|
|
212
|
+
const onField = comment({
|
|
213
|
+
_id: 'a',
|
|
214
|
+
target: {
|
|
215
|
+
documentType: 'author',
|
|
216
|
+
path: {field: 'title'},
|
|
217
|
+
document: {_ref: 'doc-1', _type: 'reference', _weak: true},
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
listeners.values().next().value!.next(WELCOME)
|
|
222
|
+
fetches[0].next([onField, comment({_id: 'b'})])
|
|
223
|
+
|
|
224
|
+
expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('filters by status', () => {
|
|
228
|
+
const source = getCommentsState(instance, {...HANDLE, status: 'resolved'})
|
|
229
|
+
source.subscribe()
|
|
230
|
+
|
|
231
|
+
listeners.values().next().value!.next(WELCOME)
|
|
232
|
+
fetches[0].next([comment({_id: 'a'}), comment({_id: 'b', status: 'resolved'})])
|
|
233
|
+
|
|
234
|
+
expect(source.getCurrent()!.map((c) => c.id)).toEqual(['b'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('keeps a release’s comments apart from the default ones', () => {
|
|
238
|
+
const base = getCommentsState(instance, HANDLE)
|
|
239
|
+
const release = getCommentsState(instance, {
|
|
240
|
+
...HANDLE,
|
|
241
|
+
perspective: {releaseName: 'summer'},
|
|
242
|
+
})
|
|
243
|
+
base.subscribe()
|
|
244
|
+
release.subscribe()
|
|
245
|
+
|
|
246
|
+
expect(listeners.size).toBe(2)
|
|
247
|
+
|
|
248
|
+
const [baseListener, releaseListener] = Array.from(listeners.values())
|
|
249
|
+
baseListener.next(WELCOME)
|
|
250
|
+
releaseListener.next(WELCOME)
|
|
251
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
252
|
+
fetches[1].next([comment({_id: 'b'})])
|
|
253
|
+
|
|
254
|
+
expect(base.getCurrent()!.map((c) => c.id)).toEqual(['a'])
|
|
255
|
+
expect(release.getCurrent()!.map((c) => c.id)).toEqual(['b'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('shares one list between a draft and its published document', () => {
|
|
259
|
+
getCommentsState(instance, HANDLE).subscribe()
|
|
260
|
+
getCommentsState(instance, {...HANDLE, documentId: 'drafts.doc-1'}).subscribe()
|
|
261
|
+
|
|
262
|
+
expect(listeners.size).toBe(1)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('transaction reconciliation', () => {
|
|
267
|
+
function updateEvent(transactionId: string, status: StoredComment['status']) {
|
|
268
|
+
return {
|
|
269
|
+
type: 'mutation',
|
|
270
|
+
transition: 'update',
|
|
271
|
+
documentId: 'a',
|
|
272
|
+
result: comment({_id: 'a', status}),
|
|
273
|
+
transactionId,
|
|
274
|
+
} as ListenEvent<StoredComment>
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function loaded() {
|
|
278
|
+
const source = getCommentsState(instance, HANDLE)
|
|
279
|
+
source.subscribe()
|
|
280
|
+
listeners.values().next().value!.next(WELCOME)
|
|
281
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
282
|
+
return source
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
it('ignores an echo from a superseded transaction', () => {
|
|
286
|
+
const source = loaded()
|
|
287
|
+
markPending(instance, {commentId: 'a', transactionId: 'tx-2'})
|
|
288
|
+
|
|
289
|
+
listeners.values().next().value!.next(updateEvent('tx-1', 'resolved'))
|
|
290
|
+
|
|
291
|
+
expect(source.getCurrent()![0].status).toBe('open')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('applies the echo we were waiting for', () => {
|
|
295
|
+
const source = loaded()
|
|
296
|
+
markPending(instance, {commentId: 'a', transactionId: 'tx-2'})
|
|
297
|
+
|
|
298
|
+
listeners.values().next().value!.next(updateEvent('tx-2', 'resolved'))
|
|
299
|
+
|
|
300
|
+
expect(source.getCurrent()![0].status).toBe('resolved')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('applies updates from other clients when nothing is pending', () => {
|
|
304
|
+
const source = loaded()
|
|
305
|
+
|
|
306
|
+
listeners.values().next().value!.next(updateEvent('tx-elsewhere', 'resolved'))
|
|
307
|
+
|
|
308
|
+
expect(source.getCurrent()![0].status).toBe('resolved')
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('getCommentThreadsState', () => {
|
|
313
|
+
it('groups comments into threads', () => {
|
|
314
|
+
const source = getCommentThreadsState(instance, HANDLE)
|
|
315
|
+
source.subscribe()
|
|
316
|
+
|
|
317
|
+
listeners.values().next().value!.next(WELCOME)
|
|
318
|
+
fetches[0].next([comment({_id: 'a'}), comment({_id: 'b', parentCommentId: 'a'})])
|
|
319
|
+
|
|
320
|
+
expect(source.getCurrent()).toMatchObject([{threadId: 'thread-1', commentsCount: 2}])
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('filters by the parent status without dropping replies', () => {
|
|
324
|
+
const source = getCommentThreadsState(instance, {...HANDLE, status: 'resolved'})
|
|
325
|
+
source.subscribe()
|
|
326
|
+
|
|
327
|
+
listeners.values().next().value!.next(WELCOME)
|
|
328
|
+
fetches[0].next([
|
|
329
|
+
comment({_id: 'parent', status: 'resolved'}),
|
|
330
|
+
comment({_id: 'reply', parentCommentId: 'parent', status: 'open'}),
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
expect(source.getCurrent()).toMatchObject([
|
|
334
|
+
{
|
|
335
|
+
parentComment: {id: 'parent'},
|
|
336
|
+
replies: [{id: 'reply'}],
|
|
337
|
+
commentsCount: 2,
|
|
338
|
+
},
|
|
339
|
+
])
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('returns the same array on repeated reads', () => {
|
|
343
|
+
// A fresh array from one read to the next would make useSyncExternalStore
|
|
344
|
+
// re-render without end.
|
|
345
|
+
const source = getCommentThreadsState(instance, HANDLE)
|
|
346
|
+
source.subscribe()
|
|
347
|
+
|
|
348
|
+
listeners.values().next().value!.next(WELCOME)
|
|
349
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
350
|
+
|
|
351
|
+
expect(source.getCurrent()).toBe(source.getCurrent())
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('returns the same array when an unrelated document loads', () => {
|
|
355
|
+
// Every state change re-runs the selector, so without a cache keyed on the
|
|
356
|
+
// data, one document loading would re-render readers of every other one.
|
|
357
|
+
const source = getCommentThreadsState(instance, HANDLE)
|
|
358
|
+
source.subscribe()
|
|
359
|
+
|
|
360
|
+
const [ownListener] = Array.from(listeners.values())
|
|
361
|
+
ownListener.next(WELCOME)
|
|
362
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
363
|
+
const before = source.getCurrent()
|
|
364
|
+
|
|
365
|
+
const other = getCommentThreadsState(instance, {...HANDLE, documentId: 'doc-2'})
|
|
366
|
+
other.subscribe()
|
|
367
|
+
Array.from(listeners.values())[1].next(WELCOME)
|
|
368
|
+
fetches[1].next([comment({_id: 'b'})])
|
|
369
|
+
|
|
370
|
+
expect(source.getCurrent()).toBe(before)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('resolveComments', () => {
|
|
375
|
+
it('starts loading and resolves with the snapshot', async () => {
|
|
376
|
+
const promise = resolveComments(instance, HANDLE)
|
|
377
|
+
|
|
378
|
+
await vi.waitFor(() => expect(listeners.size).toBe(1))
|
|
379
|
+
listeners.values().next().value!.next(WELCOME)
|
|
380
|
+
fetches[0].next([comment({_id: 'a'})])
|
|
381
|
+
|
|
382
|
+
await expect(promise).resolves.toMatchObject([{id: 'a'}])
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('rejects when aborted', async () => {
|
|
386
|
+
const controller = new AbortController()
|
|
387
|
+
const promise = resolveComments(instance, {...HANDLE, signal: controller.signal})
|
|
388
|
+
|
|
389
|
+
controller.abort()
|
|
390
|
+
|
|
391
|
+
await expect(promise).rejects.toThrow(/aborted/)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('tears the listener down when an abort leaves no readers', async () => {
|
|
395
|
+
const controller = new AbortController()
|
|
396
|
+
const promise = resolveComments(instance, {...HANDLE, signal: controller.signal})
|
|
397
|
+
await vi.waitFor(() => expect(listeners.size).toBe(1))
|
|
398
|
+
|
|
399
|
+
controller.abort()
|
|
400
|
+
await expect(promise).rejects.toThrow()
|
|
401
|
+
|
|
402
|
+
expect(listeners.values().next().value!.observed).toBe(false)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe('listener recovery', () => {
|
|
407
|
+
it('starts listening again when the addon client changes after a listener error', () => {
|
|
408
|
+
const source = getCommentsState(instance, HANDLE)
|
|
409
|
+
source.subscribe()
|
|
410
|
+
|
|
411
|
+
const failedListener = listeners.values().next().value!
|
|
412
|
+
failedListener.error(new Error('connection failed'))
|
|
413
|
+
listeners.clear()
|
|
414
|
+
|
|
415
|
+
client$.next(client)
|
|
416
|
+
|
|
417
|
+
expect(listeners.size).toBe(1)
|
|
418
|
+
})
|
|
419
|
+
})
|