@sanity/sdk 2.17.0 → 2.19.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/_chunks-dts/createGroqSearchFilter.d.ts +8 -11
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/_internal.js +1 -0
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +8 -4
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/index.d.ts +197 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -123
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/_exports/index.ts +7 -1
- package/src/document/documentStore.concurrency.test.ts +1306 -0
- package/src/document/documentStore.test.ts +11 -0
- package/src/document/documentStore.ts +0 -1
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +65 -20
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +49 -2
- package/src/document/patchOperations.ts +50 -0
- package/src/document/processActions/edit.ts +9 -2
- package/src/document/processActions/shared.ts +111 -1
- package/src/document/processActions.test.ts +185 -1
- package/src/document/reducers.test.ts +123 -0
- package/src/document/reducers.ts +37 -2
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
- 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/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
|
@@ -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('
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
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
|
+
}
|