@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.
Files changed (75) hide show
  1. package/dist/_exports/_internal.d.ts +2 -2
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +196 -22
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/authStore-CWTAuieY.js +1072 -0
  6. package/dist/authStore-CWTAuieY.js.map +1 -0
  7. package/dist/clientStore-CMzJ7XhZ.js +168 -0
  8. package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
  9. package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
  10. package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
  11. package/dist/getEnv-76AjZd_f.js +178 -0
  12. package/dist/getEnv-76AjZd_f.js.map +1 -0
  13. package/dist/index.d.ts +2 -1921
  14. package/dist/index.js +4165 -3711
  15. package/dist/index.js.map +1 -1
  16. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  17. package/dist/telemetryManager-Bd3O8z2S.js +127 -0
  18. package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
  19. package/dist/utils-BMwA8Kg9.d.ts +3334 -0
  20. package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
  21. package/dist/version-DL2l07HG.js +9 -0
  22. package/dist/version-DL2l07HG.js.map +1 -0
  23. package/package.json +13 -21
  24. package/src/_exports/_internal.ts +1 -0
  25. package/src/_exports/index.ts +37 -1
  26. package/src/comments/addonDatasetStore.test.ts +215 -0
  27. package/src/comments/addonDatasetStore.ts +249 -0
  28. package/src/comments/buildCommentThreads.test.ts +83 -0
  29. package/src/comments/buildCommentThreads.ts +54 -0
  30. package/src/comments/commentActions.test.ts +540 -0
  31. package/src/comments/commentActions.ts +555 -0
  32. package/src/comments/commentFieldPath.test.ts +40 -0
  33. package/src/comments/commentFieldPath.ts +44 -0
  34. package/src/comments/commentsConstants.ts +80 -0
  35. package/src/comments/commentsStore.test.ts +419 -0
  36. package/src/comments/commentsStore.ts +483 -0
  37. package/src/comments/normalizeComment.test.ts +116 -0
  38. package/src/comments/normalizeComment.ts +45 -0
  39. package/src/comments/observeComments.test.ts +193 -0
  40. package/src/comments/observeComments.ts +113 -0
  41. package/src/comments/reducers.test.ts +277 -0
  42. package/src/comments/reducers.ts +316 -0
  43. package/src/comments/types.ts +249 -0
  44. package/src/comments/weakenReferences.test.ts +47 -0
  45. package/src/comments/weakenReferences.ts +29 -0
  46. package/src/document/documentConstants.ts +8 -0
  47. package/src/document/documentStore.concurrency.test.ts +313 -22
  48. package/src/document/documentStore.test.ts +98 -1
  49. package/src/document/documentStore.ts +36 -5
  50. package/src/document/patchOperations.test.ts +27 -0
  51. package/src/document/patchOperations.ts +10 -1
  52. package/src/document/permissions.test.ts +3 -1
  53. package/src/document/reducers.test.ts +133 -0
  54. package/src/document/reducers.ts +18 -3
  55. package/src/document/util.test.ts +46 -0
  56. package/src/document/util.ts +37 -1
  57. package/src/presence/bifurTransport.test.ts +155 -3
  58. package/src/presence/bifurTransport.ts +79 -12
  59. package/src/presence/paths.test.ts +70 -0
  60. package/src/presence/paths.ts +41 -0
  61. package/src/presence/presenceStore.test.ts +822 -2
  62. package/src/presence/presenceStore.ts +518 -102
  63. package/src/presence/types.ts +175 -3
  64. package/src/projection/projectionStore.test.ts +0 -8
  65. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
  66. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  67. package/dist/_chunks-es/_internal.js +0 -133
  68. package/dist/_chunks-es/_internal.js.map +0 -1
  69. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
  70. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  71. package/dist/_chunks-es/telemetryManager.js +0 -93
  72. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  73. package/dist/_chunks-es/version.js +0 -7
  74. package/dist/_chunks-es/version.js.map +0 -1
  75. package/dist/index.d.ts.map +0 -1
@@ -565,6 +565,33 @@ describe('diffMatchPatch', () => {
565
565
  expect(output).toEqual(input)
566
566
  })
567
567
 
568
+ it('applies the patch when its offsets miss a multi-byte boundary', () => {
569
+ // `\u00f8` is two bytes, so this patch's offsets land inside it and the byte
570
+ // walk overshoots. The default is to throw, which escapes the rebase loop in
571
+ // `reducers.ts` and kills the whole outgoing-actions pipeline: the instance
572
+ // then silently stops writing every document. The patch itself is fine and
573
+ // still applies.
574
+ const input = {foo: 'abcdefg\u00f8'}
575
+ const patch = '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'
576
+
577
+ expect(() => diffMatchPatch(input, {foo: patch})).not.toThrow()
578
+ expect(diffMatchPatch(input, {foo: patch})).toEqual({foo: 'abcdefg\u00f8!'})
579
+ })
580
+
581
+ it('still patches the other properties in the same mutation', () => {
582
+ const input = {multibyte: 'abcdefg\u00f8', ascii: 'the quick brown fox'}
583
+
584
+ const output = diffMatchPatch(input, {
585
+ multibyte: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n',
586
+ ascii: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n',
587
+ })
588
+
589
+ expect(output).toEqual({
590
+ multibyte: 'abcdefg\u00f8!',
591
+ ascii: 'the quick brown cat',
592
+ })
593
+ })
594
+
568
595
  it('applies a diff-match-patch that makes no changes', () => {
569
596
  const input = {foo: 'unchanged'}
570
597
  const patch = '@@ -1,9 +1,9 @@\n unchanged\n'
@@ -617,7 +617,16 @@ export function diffMatchPatch(
617
617
  )
618
618
  }
619
619
 
620
- const [nextValue] = applyPatches(parsePatch(dmp), value)
620
+ // Offsets arrive as UTF-8 byte positions and can overshoot when they
621
+ // land inside a multi-byte character, which happens once the string a
622
+ // patch was computed against has diverged. The default is to throw, and
623
+ // that throw escapes the rebase loop in `reducers.ts`, kills the
624
+ // outgoing-actions pipeline, and leaves the instance silently dropping
625
+ // every write. Clamping to the nearest index applies the patch instead,
626
+ // which is what the Studio and the Portable Text editor already do.
627
+ const [nextValue] = applyPatches(parsePatch(dmp), value, {
628
+ allowExceedingIndices: true,
629
+ })
621
630
  return {path, value: nextValue}
622
631
  })
623
632
  .reduce((acc, {path, value}) => setDeep(acc, path, value), input)
@@ -266,7 +266,9 @@ describe('calculatePermissions', () => {
266
266
  _createdAt: '2025-01-01T00:00:00.000Z',
267
267
  _updatedAt: '2025-01-01T00:00:00.000Z',
268
268
  _rev: 'rev-1',
269
- _system: {createdBy},
269
+ // Content Lake sets `_system.createdBy` on canvas documents; the public
270
+ // `DocumentSystem` type doesn't declare it, so the literal needs a cast.
271
+ _system: {createdBy} as SanityDocument['_system'],
270
272
  })
271
273
 
272
274
  const editAction: DocumentAction = {
@@ -632,6 +632,60 @@ describe('revertOutgoingTransaction', () => {
632
632
  const docState = newState.documentStates[draftId]
633
633
  expect(docState?.unverifiedRevisions && docState.unverifiedRevisions['txnOut']).toBeUndefined()
634
634
  })
635
+
636
+ it('unregisters the reverted batch subscription ids, making documents with no other subscribers evictable', () => {
637
+ const draftId = getDraftId(DocumentId('doc1'))
638
+ const pubId = getPublishedId(DocumentId('doc1'))
639
+ const state: SyncTransactionState = {
640
+ queued: [],
641
+ applied: [],
642
+ outgoing: {
643
+ transactionId: 'txnRevert',
644
+ actions: [
645
+ {
646
+ type: 'document.edit',
647
+ documentId: 'doc1',
648
+ documentType: 'book',
649
+ patches: [{set: {foo: 'changed'}}],
650
+ },
651
+ ],
652
+ disableBatching: false,
653
+ batchedTransactionIds: ['txnRevert'],
654
+ outgoingActions: [],
655
+ outgoingMutations: [],
656
+ base: {},
657
+ working: {},
658
+ previous: {},
659
+ previousRevs: {},
660
+ timestamp: '2025-02-06T00:04:30.000Z',
661
+ },
662
+ grants,
663
+ documentStates: {
664
+ // only subscriber is the reverted transaction itself
665
+ [draftId]: {
666
+ id: draftId,
667
+ subscriptions: ['txnRevert'],
668
+ local: {...exampleDoc, _id: draftId, foo: 'changed', _rev: 'rev2'},
669
+ remote: {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'},
670
+ },
671
+ // also has a UI subscriber, so it should survive with only the
672
+ // transaction subscription removed
673
+ [pubId]: {
674
+ id: pubId,
675
+ subscriptions: ['txnRevert', 'sub-ui'],
676
+ local: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
677
+ remote: {...exampleDoc, _id: pubId, foo: 'pub', _rev: 'revPub'},
678
+ },
679
+ },
680
+ }
681
+ const newState = revertOutgoingTransaction(state)
682
+ expect(newState.outgoing).toBeUndefined()
683
+ // no subscribers remain, so the document state is evicted entirely
684
+ expect(newState.documentStates[draftId]).toBeUndefined()
685
+ // the UI subscription keeps the other document alive, but the reverted
686
+ // transaction id is gone from its subscriptions
687
+ expect(newState.documentStates[pubId]?.subscriptions).toEqual(['sub-ui'])
688
+ })
635
689
  })
636
690
 
637
691
  describe('applyRemoteDocument', () => {
@@ -1124,6 +1178,85 @@ describe('applyRemoteDocument', () => {
1124
1178
  })
1125
1179
  })
1126
1180
 
1181
+ describe('rebase over multi-byte text', () => {
1182
+ it('does not kill the pipeline when a plain edit rebases onto multi-byte text', () => {
1183
+ // The reported production failure: a byte-offset throw from
1184
+ // `@sanity/diff-match-patch` is a plain `Error`, and `createMutationApplier`
1185
+ // only converts to `ActionError` when `preserveOperations` is set. So for an
1186
+ // ordinary `editDocument` it escapes this rebase loop, reaches the rxjs
1187
+ // subscription, and the outgoing-actions pipeline for the whole instance
1188
+ // dies. Every later write is then dropped silently, for every document.
1189
+ const docId = getDraftId(DocumentId('doc1'))
1190
+ const originalDoc: SanityDocument = {
1191
+ ...exampleDoc,
1192
+ _id: docId,
1193
+ _rev: 'rev1',
1194
+ title: 'abcdefg\u00f8',
1195
+ }
1196
+ const locallyEditedDoc: SanityDocument = {
1197
+ ...originalDoc,
1198
+ title: 'abcdefg\u00f8!',
1199
+ _rev: 'txnLocal',
1200
+ }
1201
+
1202
+ const appliedTransaction: AppliedTransaction = {
1203
+ transactionId: 'txnLocal',
1204
+ actions: [
1205
+ {
1206
+ type: 'document.edit',
1207
+ documentId: 'doc1',
1208
+ documentType: 'author',
1209
+ patches: [{diffMatchPatch: {title: '@@ -9,1 +9,2 @@\n \u00f8\n+!\n'}}],
1210
+ },
1211
+ ],
1212
+ previous: {[docId]: originalDoc},
1213
+ base: {[docId]: originalDoc},
1214
+ working: {[docId]: locallyEditedDoc},
1215
+ previousRevs: {[docId]: 'rev1'},
1216
+ timestamp: '2025-02-06T00:16:00.000Z',
1217
+ outgoingActions: [],
1218
+ outgoingMutations: [],
1219
+ }
1220
+
1221
+ const initialState: SyncTransactionState = {
1222
+ queued: [],
1223
+ applied: [appliedTransaction],
1224
+ outgoing: undefined,
1225
+ grants,
1226
+ documentStates: {
1227
+ [docId]: {
1228
+ id: docId,
1229
+ subscriptions: ['sub1'],
1230
+ local: locallyEditedDoc,
1231
+ remote: originalDoc,
1232
+ unverifiedRevisions: {},
1233
+ },
1234
+ },
1235
+ }
1236
+
1237
+ // someone else changed the same field, so the local patch is rebased onto
1238
+ // a base its byte offsets were not computed against
1239
+ const remoteDoc: SanityDocument = {
1240
+ ...originalDoc,
1241
+ title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8',
1242
+ _rev: 'txnForeign',
1243
+ }
1244
+ const remote: RemoteDocument = {
1245
+ type: 'mutation',
1246
+ documentId: docId,
1247
+ document: remoteDoc,
1248
+ revision: 'txnForeign',
1249
+ previousRev: 'rev1',
1250
+ timestamp: '2025-02-06T00:17:00.000Z',
1251
+ mutations: [{patch: {id: docId, set: {title: '\u00f8\u00f8\u00f8\u00f8\u00f8\u00f8'}}}],
1252
+ }
1253
+
1254
+ const events = new Subject<DocumentEvent>()
1255
+
1256
+ expect(() => applyRemoteDocument(initialState, remote, events)).not.toThrow()
1257
+ })
1258
+ })
1259
+
1127
1260
  describe('rebase with preserved operations', () => {
1128
1261
  it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
1129
1262
  const docId = getDraftId(DocumentId('doc1'))
@@ -406,8 +406,23 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
406
406
 
407
407
  export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
408
408
  if (!prev.grants) return prev
409
+
410
+ // unregister the reverted batch's transaction-held subscriptions, symmetric
411
+ // with `cleanupOutgoingTransaction` on ack and `removeQueuedTransaction` on
412
+ // apply failure. without this, a reverted document's subscription IDs are
413
+ // never released and it can never be evicted
414
+ let base = prev
415
+ if (prev.outgoing) {
416
+ const ids = getDocumentIdsFromHandleLikes(prev.outgoing.actions)
417
+ for (const transactionId of prev.outgoing.batchedTransactionIds) {
418
+ for (const documentId of ids) {
419
+ base = removeSubscriptionIdFromDocument(base, documentId, transactionId)
420
+ }
421
+ }
422
+ }
423
+
409
424
  let working = Object.fromEntries(
410
- Object.entries(prev.documentStates).map(([documentId, documentState]) => [
425
+ Object.entries(base.documentStates).map(([documentId, documentState]) => [
411
426
  documentId,
412
427
  documentState?.remote,
413
428
  ]),
@@ -428,11 +443,11 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
428
443
  }
429
444
 
430
445
  return {
431
- ...prev,
446
+ ...base,
432
447
  applied: nextApplied,
433
448
  outgoing: undefined,
434
449
  documentStates: Object.fromEntries(
435
- Object.entries(prev.documentStates)
450
+ Object.entries(base.documentStates)
436
451
  .filter((e): e is [string, DocumentState] => !!e[1])
437
452
  .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
438
453
  const next: DocumentState = {
@@ -0,0 +1,46 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {getEditingDocumentId, getEffectiveDocumentId} from './util'
4
+
5
+ const PUBLISHED = 'movie-1'
6
+ const DRAFT = 'drafts.movie-1'
7
+ const VERSION = 'versions.autumn.movie-1'
8
+
9
+ describe('getEditingDocumentId', () => {
10
+ it('resolves the draft by default, which is what the Studio edits', () => {
11
+ expect(getEditingDocumentId({documentId: PUBLISHED})).toBe(DRAFT)
12
+ })
13
+
14
+ it("resolves the draft under the 'drafts' perspective", () => {
15
+ expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'drafts'})).toBe(DRAFT)
16
+ })
17
+
18
+ it("resolves the published document under the 'published' perspective", () => {
19
+ expect(getEditingDocumentId({documentId: PUBLISHED, perspective: 'published'})).toBe(PUBLISHED)
20
+ })
21
+
22
+ it('resolves the version under a release perspective', () => {
23
+ expect(
24
+ getEditingDocumentId({documentId: PUBLISHED, perspective: {releaseName: 'autumn'}}),
25
+ ).toBe(VERSION)
26
+ })
27
+
28
+ it('resolves the published document for live edit, which has no draft', () => {
29
+ expect(getEditingDocumentId({documentId: PUBLISHED, liveEdit: true})).toBe(PUBLISHED)
30
+ })
31
+
32
+ it('is idempotent when handed an already-specific id', () => {
33
+ expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
34
+ expect(getEditingDocumentId({documentId: VERSION, perspective: 'published'})).toBe(PUBLISHED)
35
+ expect(getEditingDocumentId({documentId: DRAFT, perspective: {releaseName: 'autumn'}})).toBe(
36
+ VERSION,
37
+ )
38
+ })
39
+
40
+ it('differs from getEffectiveDocumentId precisely where it has to', () => {
41
+ // The canonical key collapses a draft onto its published id, which is right for
42
+ // storing state and wrong for anything comparing exact ids.
43
+ expect(getEffectiveDocumentId({documentId: DRAFT, documentType: 'movie'})).toBe(PUBLISHED)
44
+ expect(getEditingDocumentId({documentId: DRAFT})).toBe(DRAFT)
45
+ })
46
+ })
@@ -1,8 +1,44 @@
1
- import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
2
 
3
3
  import {type DocumentHandle} from '../config/sanityConfig'
4
4
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
5
 
6
+ /**
7
+ * The id of the specific document a user is editing under a given perspective:
8
+ * the draft, the published document, or a release version.
9
+ *
10
+ * Distinct from {@link getEffectiveDocumentId}, which resolves the canonical key a
11
+ * document's state is stored under and so collapses a draft onto its published id.
12
+ * This one keeps them apart, because some things compare the exact id. Presence is
13
+ * the motivating case: the Studio's field indicators match the id its form is on,
14
+ * exactly, so an app reporting the published id shows up in the Studio at document
15
+ * level while never lighting up a single field.
16
+ *
17
+ * @remarks
18
+ * Resolved from the handle alone, which is right whenever a draft or version
19
+ * exists, so for any document actually being edited. The Studio additionally falls
20
+ * back to whichever document exists, so for a published document nobody has edited
21
+ * yet its form sits on the published id where this returns the draft id. Matching
22
+ * that would mean reading document state, which presence deliberately does not
23
+ * depend on.
24
+ *
25
+ * @internal
26
+ */
27
+ export function getEditingDocumentId(
28
+ doc: Pick<DocumentHandle, 'documentId' | 'liveEdit' | 'perspective'>,
29
+ ): string {
30
+ if (doc.liveEdit) {
31
+ return getPublishedId(DocumentId(doc.documentId))
32
+ }
33
+ if (isReleasePerspective(doc.perspective)) {
34
+ return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
35
+ }
36
+ if (doc.perspective === 'published') {
37
+ return getPublishedId(DocumentId(doc.documentId))
38
+ }
39
+ return getDraftId(DocumentId(doc.documentId))
40
+ }
41
+
6
42
  export function getEffectiveDocumentId(doc: DocumentHandle): string {
7
43
  if (doc.liveEdit) {
8
44
  return doc.documentId
@@ -1,7 +1,7 @@
1
1
  import {fromUrl} from '@sanity/bifur-client'
2
2
  import {type SanityClient} from '@sanity/client'
3
- import {of, Subject} from 'rxjs'
4
- import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
3
+ import {type Observable, of, Subject} from 'rxjs'
4
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5
5
 
6
6
  import {createBifurTransport} from './bifurTransport'
7
7
  import {type PresenceLocation, type TransportEvent} from './types'
@@ -39,16 +39,20 @@ describe('createBifurTransport', () => {
39
39
  let mockBifurClient: {
40
40
  listen: Mock
41
41
  request: Mock
42
+ heartbeats: Observable<Date>
42
43
  }
43
44
  let mockSanityClient: SanityClient
44
45
  let token$: Subject<string | null>
46
+ let heartbeats$: Subject<Date>
45
47
 
46
48
  beforeEach(() => {
47
49
  vi.useFakeTimers()
48
50
  vi.clearAllMocks()
51
+ heartbeats$ = new Subject<Date>()
49
52
  mockBifurClient = {
50
53
  listen: vi.fn(() => new Subject<never>()),
51
54
  request: vi.fn(() => of(undefined)),
55
+ heartbeats: heartbeats$.asObservable(),
52
56
  }
53
57
  fromUrlMock.mockReturnValue(mockBifurClient)
54
58
 
@@ -130,6 +134,8 @@ describe('createBifurTransport', () => {
130
134
 
131
135
  const receivedEvents: TransportEvent[] = []
132
136
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
137
+ // The listener is established per live connection, so bring one up first.
138
+ heartbeats$.next(new Date())
133
139
 
134
140
  incomingBifurEvents$.next({
135
141
  type: 'rollCall',
@@ -161,6 +167,8 @@ describe('createBifurTransport', () => {
161
167
 
162
168
  const receivedEvents: TransportEvent[] = []
163
169
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
170
+ // The listener is established per live connection, so bring one up first.
171
+ heartbeats$.next(new Date())
164
172
 
165
173
  const locations: PresenceLocation[] = [
166
174
  {type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
@@ -200,6 +208,8 @@ describe('createBifurTransport', () => {
200
208
 
201
209
  const receivedEvents: TransportEvent[] = []
202
210
  incomingEvents$.subscribe((event) => receivedEvents.push(event))
211
+ // The listener is established per live connection, so bring one up first.
212
+ heartbeats$.next(new Date())
203
213
 
204
214
  incomingBifurEvents$.next({
205
215
  type: 'disconnect',
@@ -233,6 +243,7 @@ describe('createBifurTransport', () => {
233
243
  incomingEvents$.subscribe({
234
244
  error: (err) => errors.push(err),
235
245
  })
246
+ heartbeats$.next(new Date())
236
247
 
237
248
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
249
  incomingBifurEvents$.next({type: 'unknown'} as any)
@@ -242,7 +253,148 @@ describe('createBifurTransport', () => {
242
253
  expect(errors[0].message).toContain('Got unknown presence event')
243
254
  })
244
255
 
245
- describe('dispatchMessage', () => {
256
+ describe('connections', () => {
257
+ it('emits an incrementing generation each time the socket becomes live', () => {
258
+ const [, , connections$] = createBifurTransport({
259
+ client: mockSanityClient,
260
+ token$,
261
+ sessionId: 'my-session',
262
+ })
263
+
264
+ const generations: number[] = []
265
+ connections$.subscribe((generation) => generations.push(generation))
266
+
267
+ // Several heartbeats on one live socket are a single generation.
268
+ heartbeats$.next(new Date())
269
+ heartbeats$.next(new Date())
270
+ expect(generations).toEqual([1])
271
+
272
+ // bifur-client errors the stream when the socket closes. The transport is
273
+ // expected to reconnect rather than give up for the life of the page.
274
+ const reconnected$ = new Subject<Date>()
275
+ mockBifurClient.heartbeats = reconnected$.asObservable()
276
+ heartbeats$.error(new Error('WebSocket connection error'))
277
+
278
+ // First retry is scheduled 200ms out (2 ** 1 * 100).
279
+ vi.advanceTimersByTime(200)
280
+ reconnected$.next(new Date())
281
+
282
+ expect(generations).toEqual([1, 2])
283
+ })
284
+
285
+ it('does not resubscribe before the backoff has elapsed', () => {
286
+ const [, , connections$] = createBifurTransport({
287
+ client: mockSanityClient,
288
+ token$,
289
+ sessionId: 'my-session',
290
+ })
291
+
292
+ const generations: number[] = []
293
+ connections$.subscribe((generation) => generations.push(generation))
294
+ heartbeats$.next(new Date())
295
+
296
+ const reconnected$ = new Subject<Date>()
297
+ mockBifurClient.heartbeats = reconnected$.asObservable()
298
+ heartbeats$.error(new Error('WebSocket connection error'))
299
+
300
+ vi.advanceTimersByTime(199)
301
+ reconnected$.next(new Date())
302
+ expect(generations).toEqual([1])
303
+ })
304
+ })
305
+
306
+ describe('incoming events across a reconnect', () => {
307
+ it('keeps delivering presence after the socket drops', () => {
308
+ // bifur errors every stream on the same socket close, the inbound listener
309
+ // included. If only the heartbeat stream recovers, this client re-announces
310
+ // itself to peers while permanently ceasing to see any of them.
311
+ const firstListen$ = new Subject<IncomingBifurEvent>()
312
+ const secondListen$ = new Subject<IncomingBifurEvent>()
313
+ mockBifurClient.listen.mockReturnValueOnce(firstListen$).mockReturnValueOnce(secondListen$)
314
+
315
+ const [incomingEvents$, , connections$] = createBifurTransport({
316
+ client: mockSanityClient,
317
+ token$,
318
+ sessionId: 'my-session',
319
+ })
320
+
321
+ const received: TransportEvent[] = []
322
+ incomingEvents$.subscribe({
323
+ next: (event) => received.push(event),
324
+ error: () => received.push({type: 'rollCall', userId: 'STREAM_DIED', sessionId: ''}),
325
+ })
326
+ connections$.subscribe()
327
+
328
+ heartbeats$.next(new Date())
329
+ firstListen$.next({type: 'rollCall', i: 'user-1', session: 'before'})
330
+ expect(received.map((e) => e.sessionId)).toEqual(['before'])
331
+
332
+ // Socket closes: both streams error.
333
+ const reconnectedHeartbeats$ = new Subject<Date>()
334
+ mockBifurClient.heartbeats = reconnectedHeartbeats$.asObservable()
335
+ firstListen$.error(new Error('WebSocket connection error'))
336
+ heartbeats$.error(new Error('WebSocket connection error'))
337
+
338
+ vi.advanceTimersByTime(200)
339
+ reconnectedHeartbeats$.next(new Date())
340
+
341
+ // Peers answer the roll call we send on reconnect. We have to be listening.
342
+ secondListen$.next({type: 'rollCall', i: 'user-1', session: 'after'})
343
+
344
+ expect(received.map((e) => e.sessionId)).toEqual(['before', 'after'])
345
+ })
346
+ })
347
+
348
+ describe('unload', () => {
349
+ afterEach(() => {
350
+ vi.unstubAllGlobals()
351
+ })
352
+
353
+ it('emits on beforeunload and pagehide, and removes its listeners', () => {
354
+ // These tests run in Node, so stand in a bare EventTarget for `window`.
355
+ const fakeWindow = new EventTarget()
356
+ vi.stubGlobal('window', fakeWindow)
357
+
358
+ const [, , , unload$] = createBifurTransport({
359
+ client: mockSanityClient,
360
+ token$,
361
+ sessionId: 'my-session',
362
+ })
363
+
364
+ let emissions = 0
365
+ const subscription = unload$.subscribe(() => {
366
+ emissions += 1
367
+ })
368
+
369
+ fakeWindow.dispatchEvent(new Event('beforeunload'))
370
+ expect(emissions).toBe(1)
371
+
372
+ // `pagehide` matters on its own: iOS Safari and pages entering the
373
+ // back/forward cache never fire `beforeunload`.
374
+ fakeWindow.dispatchEvent(new Event('pagehide'))
375
+ expect(emissions).toBe(2)
376
+
377
+ // The listeners belong to the subscription, so unsubscribing removes them
378
+ // instead of leaking one per transport for the life of the page.
379
+ subscription.unsubscribe()
380
+ fakeWindow.dispatchEvent(new Event('beforeunload'))
381
+ expect(emissions).toBe(2)
382
+ })
383
+
384
+ it('is inert when there is no window', () => {
385
+ const [, , , unload$] = createBifurTransport({
386
+ client: mockSanityClient,
387
+ token$,
388
+ sessionId: 'my-session',
389
+ })
390
+
391
+ let completed = false
392
+ unload$.subscribe({complete: () => (completed = true)})
393
+ expect(completed).toBe(true)
394
+ })
395
+ })
396
+
397
+ describe('dispatch', () => {
246
398
  it('sends a "rollCall" message', () => {
247
399
  const [, dispatchMessage] = createBifurTransport({
248
400
  client: mockSanityClient,
@@ -1,7 +1,7 @@
1
1
  import {type BifurClient, fromUrl} from '@sanity/bifur-client'
2
2
  import {type SanityClient} from '@sanity/client'
3
- import {EMPTY, fromEvent, type Observable} from 'rxjs'
4
- import {map, share, switchMap} from 'rxjs/operators'
3
+ import {defer, EMPTY, fromEvent, merge, type Observable, timer} from 'rxjs'
4
+ import {catchError, distinctUntilChanged, map, retry, share, switchMap} from 'rxjs/operators'
5
5
 
6
6
  import {
7
7
  type BifurTransportOptions,
@@ -11,6 +11,12 @@ import {
11
11
  type TransportMessage,
12
12
  } from './types'
13
13
 
14
+ /**
15
+ * Longest gap between reconnection attempts, matching the Studio's presence
16
+ * transport so both behave the same way on a flaky connection.
17
+ */
18
+ const CONNECT_RETRY_MAX_DELAY = 1000 * 240
19
+
14
20
  type BifurStateMessage = {
15
21
  type: 'state'
16
22
  i: string
@@ -91,13 +97,80 @@ const handleIncomingMessage = (event: IncomingBifurEvent): TransportEvent => {
91
97
  }
92
98
  }
93
99
 
100
+ /**
101
+ * Emits an incrementing generation number each time the socket becomes live.
102
+ *
103
+ * `@sanity/bifur-client` does not reconnect on its own — it errors the stream
104
+ * when the socket closes. Without the `retry` below, presence stops for the
105
+ * life of the page after the first dropped connection.
106
+ */
107
+ const createConnections = (bifur: BifurClient): Observable<number> => {
108
+ let generation = 0
109
+
110
+ return defer(() => {
111
+ const current = ++generation
112
+ // `heartbeats` emits as soon as the socket is open and authorized, then on
113
+ // every server heartbeat and every response. Subscribing to it is also what
114
+ // keeps the socket alive between requests.
115
+ return bifur.heartbeats.pipe(map(() => current))
116
+ }).pipe(
117
+ retry({
118
+ delay: (_error, retryCount) =>
119
+ timer(Math.min(CONNECT_RETRY_MAX_DELAY, 2 ** retryCount * 100)),
120
+ resetOnSuccess: true,
121
+ }),
122
+ // After `retry` so that one operator instance spans reconnects, making each
123
+ // live socket yield exactly one emission.
124
+ distinctUntilChanged(),
125
+ share(),
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Presence events from everyone else in the room, re-established on every live
131
+ * connection.
132
+ *
133
+ * A socket close errors this stream just as it errors the heartbeats, so without
134
+ * re-subscribing the client would keep announcing itself after a reconnect while
135
+ * never hearing from anyone again. Driving it from `connections$` rather than
136
+ * giving it its own `retry` keeps a single reconnect loop, instead of two
137
+ * independent backoffs racing over one refcounted socket.
138
+ */
139
+ const createIncomingEvents = (
140
+ bifur: BifurClient,
141
+ connections$: Observable<number>,
142
+ ): Observable<TransportEvent> =>
143
+ connections$.pipe(
144
+ switchMap(() =>
145
+ bifur.listen<IncomingBifurEvent>('presence').pipe(
146
+ // Let a dead listener go quietly. The next connection replaces it, and
147
+ // surfacing the error here would tear down the reconnect loop with it.
148
+ catchError(() => EMPTY),
149
+ ),
150
+ ),
151
+ map(handleIncomingMessage),
152
+ share(),
153
+ )
154
+
155
+ /**
156
+ * Emits when the page is going away. `pagehide` is needed alongside
157
+ * `beforeunload` because iOS Safari and pages entering the back/forward cache
158
+ * never fire `beforeunload`.
159
+ */
160
+ const createUnload = (): Observable<void> => {
161
+ if (typeof window === 'undefined') return EMPTY
162
+
163
+ return merge(fromEvent(window, 'beforeunload'), fromEvent(window, 'pagehide')).pipe(
164
+ map(() => undefined),
165
+ )
166
+ }
167
+
94
168
  export const createBifurTransport = (options: BifurTransportOptions): PresenceTransport => {
95
169
  const {client, token$, sessionId} = options
96
170
  const bifur = getBifurClient(client, token$)
97
171
 
98
- const incomingEvents$: Observable<TransportEvent> = bifur
99
- .listen<IncomingBifurEvent>('presence')
100
- .pipe(map(handleIncomingMessage))
172
+ const connections$ = createConnections(bifur)
173
+ const incomingEvents$ = createIncomingEvents(bifur, connections$)
101
174
 
102
175
  const dispatchMessage = (message: TransportMessage): Observable<void> => {
103
176
  switch (message.type) {
@@ -115,11 +188,5 @@ export const createBifurTransport = (options: BifurTransportOptions): PresenceTr
115
188
  }
116
189
  }
117
190
 
118
- if (typeof window !== 'undefined') {
119
- fromEvent(window, 'beforeunload')
120
- .pipe(switchMap(() => dispatchMessage({type: 'disconnect'})))
121
- .subscribe()
122
- }
123
-
124
- return [incomingEvents$.pipe(share()), dispatchMessage]
191
+ return [incomingEvents$, dispatchMessage, connections$, createUnload()]
125
192
  }