@sanity/sdk 3.0.0-rc.1 → 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +71 -3
  3. package/dist/_exports/_internal.d.ts.map +1 -1
  4. package/dist/_exports/_internal.js +332 -24
  5. package/dist/_exports/_internal.js.map +1 -1
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2335
  29. package/dist/index.js +4048 -3874
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +13 -19
  45. package/src/_exports/_internal.ts +1 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +38 -86
  49. package/src/applications/applications.test-d.ts +5 -0
  50. package/src/applications/applications.ts +5 -2
  51. package/src/auth/authMode.test.ts +0 -25
  52. package/src/auth/authMode.ts +3 -6
  53. package/src/auth/authStore.test.ts +1 -1
  54. package/src/auth/authStore.ts +0 -1
  55. package/src/comments/addonDatasetStore.test.ts +215 -0
  56. package/src/comments/addonDatasetStore.ts +249 -0
  57. package/src/comments/buildCommentThreads.test.ts +83 -0
  58. package/src/comments/buildCommentThreads.ts +54 -0
  59. package/src/comments/commentActions.test.ts +540 -0
  60. package/src/comments/commentActions.ts +555 -0
  61. package/src/comments/commentFieldPath.test.ts +40 -0
  62. package/src/comments/commentFieldPath.ts +44 -0
  63. package/src/comments/commentsConstants.ts +80 -0
  64. package/src/comments/commentsStore.test.ts +419 -0
  65. package/src/comments/commentsStore.ts +483 -0
  66. package/src/comments/normalizeComment.test.ts +116 -0
  67. package/src/comments/normalizeComment.ts +45 -0
  68. package/src/comments/observeComments.test.ts +193 -0
  69. package/src/comments/observeComments.ts +113 -0
  70. package/src/comments/reducers.test.ts +277 -0
  71. package/src/comments/reducers.ts +316 -0
  72. package/src/comments/types.ts +249 -0
  73. package/src/comments/weakenReferences.test.ts +47 -0
  74. package/src/comments/weakenReferences.ts +29 -0
  75. package/src/config/sanityConfig.ts +3 -75
  76. package/src/document/applyDocumentActions.test.ts +8 -12
  77. package/src/document/documentConstants.ts +8 -0
  78. package/src/document/documentStore.test.ts +104 -19
  79. package/src/document/documentStore.ts +36 -5
  80. package/src/document/patchOperations.test.ts +27 -0
  81. package/src/document/patchOperations.ts +10 -1
  82. package/src/document/permissions.test.ts +3 -1
  83. package/src/document/reducers.test.ts +133 -0
  84. package/src/document/reducers.ts +18 -3
  85. package/src/document/util.test.ts +46 -0
  86. package/src/document/util.ts +37 -1
  87. package/src/installations/installations.test-d.ts +5 -0
  88. package/src/installations/installations.ts +4 -0
  89. package/src/presence/bifurTransport.test.ts +155 -3
  90. package/src/presence/bifurTransport.ts +79 -12
  91. package/src/presence/paths.test.ts +70 -0
  92. package/src/presence/paths.ts +41 -0
  93. package/src/presence/presenceStore.test.ts +822 -2
  94. package/src/presence/presenceStore.ts +518 -102
  95. package/src/presence/types.ts +175 -3
  96. package/src/preview/types.ts +0 -25
  97. package/src/projection/projectionStore.test.ts +0 -8
  98. package/src/projection/types.ts +0 -8
  99. package/src/query/queryStore.ts +7 -6
  100. package/src/store/createActionBinder.test.ts +51 -29
  101. package/src/store/createActionBinder.ts +16 -62
  102. package/src/store/createSanityInstance.test.ts +0 -68
  103. package/src/store/createSanityInstance.ts +0 -63
  104. package/src/store/createStateSourceAction.ts +1 -1
  105. package/src/users/reducers.test.ts +0 -3
  106. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  107. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  108. package/dist/_chunks-es/_internal.js +0 -134
  109. package/dist/_chunks-es/_internal.js.map +0 -1
  110. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  111. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  112. package/dist/_chunks-es/telemetryManager.js +0 -93
  113. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  114. package/dist/index.d.ts.map +0 -1
  115. package/src/applications/userApplications.test.ts +0 -126
  116. package/src/applications/userApplications.ts +0 -261
  117. package/src/preview/getPreviewState.test.ts +0 -137
  118. package/src/preview/getPreviewState.ts +0 -69
  119. package/src/preview/resolvePreview.test.ts +0 -64
  120. package/src/preview/resolvePreview.ts +0 -43
@@ -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
@@ -29,6 +29,11 @@ test('Installation — no includes: only the base shape', () => {
29
29
  expectTypeOf<Extract<keyof Installation, IncludeKeys>>().toEqualTypeOf<never>()
30
30
  })
31
31
 
32
+ test('InstallationBase — the application sub-object carries name and reference', () => {
33
+ expectTypeOf<InstallationBase['application']['name']>().toEqualTypeOf<string>()
34
+ expectTypeOf<InstallationBase['application']['reference']>().toEqualTypeOf<string>()
35
+ })
36
+
32
37
  test('Installation — each token adds its top-level field, required, others absent', () => {
33
38
  expectTypeOf<Installation<'access'>['access']>().toEqualTypeOf<InstallationAccess[]>()
34
39
  expectTypeOf<
@@ -79,6 +79,10 @@ export interface InstallationBase {
79
79
  updatedAt: string
80
80
  application: {
81
81
  title: string
82
+ /** Stable, immutable identity of the installed singleton, distinct from `slug`. */
83
+ name: string
84
+ /** Qualified, globally-unique handle of the installed singleton (e.g. `sanity/<name>`). */
85
+ reference: string
82
86
  slug: string | null
83
87
  }
84
88
  }
@@ -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
  }
@@ -0,0 +1,70 @@
1
+ import {type Path} from '@sanity/types'
2
+ import {describe, expect, it} from 'vitest'
3
+
4
+ import {startsWithPath} from './paths'
5
+
6
+ describe('startsWithPath', () => {
7
+ it('treats the empty path as a prefix of everything, which is the document root', () => {
8
+ expect(startsWithPath([], ['title'])).toBe(true)
9
+ expect(startsWithPath([], [])).toBe(true)
10
+ })
11
+
12
+ it('matches a path against itself', () => {
13
+ expect(startsWithPath(['title'], ['title'])).toBe(true)
14
+ })
15
+
16
+ it('matches a descendant', () => {
17
+ expect(startsWithPath(['body'], ['body', 'children'])).toBe(true)
18
+ })
19
+
20
+ it('does not match an ancestor, since a longer prefix cannot fit', () => {
21
+ expect(startsWithPath(['body', 'children'], ['body'])).toBe(false)
22
+ })
23
+
24
+ it('does not match a sibling', () => {
25
+ expect(startsWithPath(['title'], ['subtitle'])).toBe(false)
26
+ })
27
+
28
+ it('does not match a partial segment name', () => {
29
+ // `title` must not be treated as a prefix of `titleImage`.
30
+ expect(startsWithPath(['title'], ['titleImage'])).toBe(false)
31
+ })
32
+
33
+ it('compares keyed segments by key rather than by identity', () => {
34
+ // Distinct objects with the same key, which is what arrives over the wire.
35
+ expect(startsWithPath(['cast', {_key: 'a'}], ['cast', {_key: 'a'}, 'name'])).toBe(true)
36
+ expect(startsWithPath(['cast', {_key: 'a'}], ['cast', {_key: 'b'}, 'name'])).toBe(false)
37
+ })
38
+
39
+ it('does not confuse a keyed segment with a string or a number', () => {
40
+ expect(startsWithPath([{_key: 'a'}], ['a'])).toBe(false)
41
+ expect(startsWithPath(['a'], [{_key: 'a'}])).toBe(false)
42
+ expect(startsWithPath([{_key: '0'}], [0])).toBe(false)
43
+ })
44
+
45
+ it('compares numeric indexes', () => {
46
+ expect(startsWithPath(['awards', 0], ['awards', 0])).toBe(true)
47
+ expect(startsWithPath(['awards', 0], ['awards', 1])).toBe(false)
48
+ })
49
+
50
+ it('compares index tuples structurally', () => {
51
+ const prefix: Path = ['body', [0, 2]]
52
+ expect(startsWithPath(prefix, ['body', [0, 2], 'children'])).toBe(true)
53
+ expect(startsWithPath(prefix, ['body', [0, 3]])).toBe(false)
54
+ expect(startsWithPath(prefix, ['body', [0, '']])).toBe(false)
55
+ })
56
+
57
+ it('does not confuse a tuple with a keyed segment', () => {
58
+ expect(startsWithPath([[0, 2]], [{_key: 'a'}])).toBe(false)
59
+ expect(startsWithPath([{_key: 'a'}], [[0, 2]])).toBe(false)
60
+ })
61
+
62
+ it('handles a realistic Portable Text span path', () => {
63
+ const field: Path = ['body']
64
+ const span: Path = ['body', {_key: 'block-1'}, 'children', {_key: 'span-1'}, 'text']
65
+
66
+ expect(startsWithPath(field, span)).toBe(true)
67
+ expect(startsWithPath(['title'], span)).toBe(false)
68
+ expect(startsWithPath(['body', {_key: 'block-2'}], span)).toBe(false)
69
+ })
70
+ })
@@ -0,0 +1,41 @@
1
+ import {type IndexTuple, type KeyedSegment, type Path, type PathSegment} from '@sanity/types'
2
+
3
+ /**
4
+ * Segments are not always strings, despite what `PresenceLocation.path` declares.
5
+ * Array items arrive as `{_key}` and Portable Text spans as a mix of keys and
6
+ * property names, because that is what the Studio sends.
7
+ */
8
+ function isKeyedSegment(segment: PathSegment): segment is KeyedSegment {
9
+ return (
10
+ typeof segment === 'object' && segment !== null && !Array.isArray(segment) && '_key' in segment
11
+ )
12
+ }
13
+
14
+ /**
15
+ * Index tuples (`[from, to]`) only occur in slice selections, which presence never
16
+ * reports. Compared structurally rather than assumed absent.
17
+ */
18
+ function isEqualTuple(a: IndexTuple, b: IndexTuple): boolean {
19
+ return a.length === b.length && a.every((value, index) => value === b[index])
20
+ }
21
+
22
+ /** Compares two path segments, whatever shape they arrive in. */
23
+ function isEqualSegment(a: PathSegment, b: PathSegment): boolean {
24
+ if (a === b) return true
25
+ if (isKeyedSegment(a) && isKeyedSegment(b)) return a._key === b._key
26
+ if (Array.isArray(a) && Array.isArray(b)) return isEqualTuple(a, b)
27
+ return false
28
+ }
29
+
30
+ /**
31
+ * True when `candidate` is at or below `prefix` in the document tree.
32
+ *
33
+ * Equivalent to `startsWith` from the Studio's `@sanity/util/paths`, which is not
34
+ * an SDK dependency.
35
+ *
36
+ * @internal
37
+ */
38
+ export function startsWithPath(prefix: Path, candidate: Path): boolean {
39
+ if (prefix.length > candidate.length) return false
40
+ return prefix.every((segment, index) => isEqualSegment(segment, candidate[index]))
41
+ }