@sanity/diff-patch 5.0.0 → 7.0.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/src/diffPatch.ts CHANGED
@@ -1,41 +1,44 @@
1
- import {cleanupEfficiency, makeDiff, makePatches, stringifyPatches} from '@sanity/diff-match-patch'
1
+ import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
2
2
  import {DiffError} from './diffError.js'
3
- import {type Path, pathToString} from './paths.js'
3
+ import {isKeyedObject, type KeyedSanityObject, type Path, pathToString} from './paths.js'
4
4
  import {validateProperty} from './validate.js'
5
5
  import {
6
6
  type Patch,
7
- type SetPatch,
8
7
  type UnsetPatch,
9
- type InsertAfterPatch,
10
8
  type DiffMatchPatch,
11
- type SanityInsertPatch,
12
- type SanityPatch,
13
- type SanitySetPatch,
14
- type SanityUnsetPatch,
15
- type SanityDiffMatchPatch,
16
9
  type SanityPatchMutation,
10
+ type SanityPatchOperations,
11
+ type SanitySetPatchOperation,
12
+ type SanityUnsetPatchOperation,
13
+ type SanityInsertPatchOperation,
14
+ type SanityDiffMatchPatchOperation,
17
15
  } from './patches.js'
16
+ import {difference, intersection} from './setOperations.js'
18
17
 
19
- const ignoredKeys = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
18
+ /**
19
+ * Document keys that are ignored during diff operations.
20
+ * These are system-managed fields that should not be included in patches on
21
+ * top-level documents and should not be diffed with diff-match-patch.
22
+ */
23
+ const SYSTEM_KEYS = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
20
24
 
21
- type PrimitiveValue = string | number | boolean | null | undefined
25
+ /**
26
+ * Maximum size of strings to consider for diff-match-patch (1MB)
27
+ * Based on testing showing consistently good performance up to this size
28
+ */
29
+ const DMP_MAX_STRING_SIZE = 1_000_000
22
30
 
23
31
  /**
24
- * An object (record) that has a `_key` property
25
- *
26
- * @internal
32
+ * Maximum difference in string length before falling back to set operations (40%)
33
+ * Above this threshold, likely indicates text replacement which can be slow
27
34
  */
28
- export interface KeyedSanityObject {
29
- [key: string]: unknown
30
- _key: string
31
- }
35
+ const DMP_MAX_STRING_LENGTH_CHANGE_RATIO = 0.4
32
36
 
33
37
  /**
34
- * An object (record) that _may_ have a `_key` property
35
- *
36
- * @internal
38
+ * Minimum string size to apply change ratio check (10KB)
39
+ * Small strings are always fast regardless of change ratio
37
40
  */
38
- export type SanityObject = KeyedSanityObject | Partial<KeyedSanityObject>
41
+ const DMP_MIN_SIZE_FOR_RATIO_CHECK = 10_000
39
42
 
40
43
  /**
41
44
  * Represents a partial Sanity document (eg a "stub").
@@ -51,37 +54,6 @@ export interface DocumentStub {
51
54
  [key: string]: unknown
52
55
  }
53
56
 
54
- /**
55
- * Options for the diff-match-patch algorithm.
56
- *
57
- * @public
58
- */
59
- export interface DiffMatchPatchOptions {
60
- /**
61
- * Whether or not to use diff-match-patch at all
62
- *
63
- * @defaultValue `true`
64
- */
65
- enabled: boolean
66
-
67
- /**
68
- * Threshold at which to start using diff-match-patch instead of a regular `set` patch.
69
- *
70
- * @defaultValue `30`
71
- */
72
- lengthThresholdAbsolute: number
73
-
74
- /**
75
- * Only use generated diff-match-patch if the patch length is less than or equal to
76
- * (targetString * relative). Example: A 100 character target with a relative factor
77
- * of 1.2 will allow a 120 character diff-match-patch. If larger than this number,
78
- * it will fall back to a regular `set` patch.
79
- *
80
- * @defaultValue `1.2`
81
- */
82
- lengthThresholdRelative: number
83
- }
84
-
85
57
  /**
86
58
  * Options for the patch generator
87
59
  *
@@ -110,80 +82,30 @@ export interface PatchOptions {
110
82
  * @defaultValue `undefined` (do not apply revision check)
111
83
  */
112
84
  ifRevisionID?: string | true
113
-
114
- /**
115
- * Whether or not to hide warnings during the diff process.
116
- *
117
- * @defaultValue `false`
118
- */
119
- hideWarnings?: boolean
120
-
121
- /**
122
- * Options for the diff-match-patch algorithm.
123
- */
124
- diffMatchPatch?: Partial<DiffMatchPatchOptions>
125
- }
126
-
127
- /**
128
- * Options for diff generation, where all DMP properties are required
129
- *
130
- * @public
131
- */
132
- export type DiffOptions = PatchOptions & {diffMatchPatch: Required<DiffMatchPatchOptions>}
133
-
134
- const defaultOptions = {
135
- hideWarnings: false,
136
- diffMatchPatch: {
137
- enabled: true,
138
-
139
- // Only use diff-match-patch if target string is longer than this threshold
140
- lengthThresholdAbsolute: 30,
141
-
142
- // Only use generated diff-match-patch if the patch length is less than or equal to
143
- // (targetString * relative). Example: A 100 character target with a relative factor
144
- // of 1.2 will allow a 120 character diff-match-patch. If larger than this number,
145
- // it will fall back to a regular `set` patch.
146
- lengthThresholdRelative: 1.2,
147
- },
148
- } satisfies DiffOptions
149
-
150
- /**
151
- * Merges the default options with the passed in options.
152
- *
153
- * @param options - Options to merge with the defaults
154
- * @returns Merged options
155
- */
156
- function mergeOptions(options: PatchOptions): DiffOptions {
157
- return {
158
- ...defaultOptions,
159
- ...options,
160
- diffMatchPatch: {...defaultOptions.diffMatchPatch, ...(options.diffMatchPatch || {})},
161
- }
162
85
  }
163
86
 
164
87
  /**
165
88
  * Generates an array of mutations for Sanity, based on the differences between
166
89
  * the two passed documents/trees.
167
90
  *
168
- * @param itemA - The first document/tree to compare
169
- * @param itemB - The second document/tree to compare
91
+ * @param source - The first document/tree to compare
92
+ * @param target - The second document/tree to compare
170
93
  * @param opts - Options for the diff generation
171
94
  * @returns Array of mutations
172
95
  * @public
173
96
  */
174
97
  export function diffPatch(
175
- itemA: DocumentStub,
176
- itemB: DocumentStub,
177
- opts?: PatchOptions,
98
+ source: DocumentStub,
99
+ target: DocumentStub,
100
+ options: PatchOptions = {},
178
101
  ): SanityPatchMutation[] {
179
- const options = mergeOptions(opts || {})
180
- const id = options.id || (itemA._id === itemB._id && itemA._id)
102
+ const id = options.id || (source._id === target._id && source._id)
181
103
  const revisionLocked = options.ifRevisionID
182
- const ifRevisionID = typeof revisionLocked === 'boolean' ? itemA._rev : revisionLocked
104
+ const ifRevisionID = typeof revisionLocked === 'boolean' ? source._rev : revisionLocked
183
105
  const basePath = options.basePath || []
184
106
  if (!id) {
185
107
  throw new Error(
186
- '_id on itemA and itemB not present or differs, specify document id the mutations should be applied to',
108
+ '_id on source and target not present or differs, specify document id the mutations should be applied to',
187
109
  )
188
110
  }
189
111
 
@@ -193,94 +115,95 @@ export function diffPatch(
193
115
  )
194
116
  }
195
117
 
196
- if (basePath.length === 0 && itemA._type !== itemB._type) {
197
- throw new Error(`_type is immutable and cannot be changed (${itemA._type} => ${itemB._type})`)
118
+ if (basePath.length === 0 && source._type !== target._type) {
119
+ throw new Error(`_type is immutable and cannot be changed (${source._type} => ${target._type})`)
198
120
  }
199
121
 
200
- const operations = diffItem(itemA, itemB, options, basePath, [])
201
- return serializePatches(operations, {id, ifRevisionID: revisionLocked ? ifRevisionID : undefined})
122
+ const operations = diffItem(source, target, basePath, [])
123
+ return serializePatches(operations).map((patchOperations, i) => ({
124
+ patch: {
125
+ id,
126
+ // only add `ifRevisionID` to the first patch
127
+ ...(i === 0 && ifRevisionID && {ifRevisionID}),
128
+ ...patchOperations,
129
+ },
130
+ }))
202
131
  }
203
132
 
204
133
  /**
205
- * Diffs two items and returns an array of patches.
206
- * Note that this is different from `diffPatch`, which generates _mutations_.
134
+ * Generates an array of patch operation objects for Sanity, based on the
135
+ * differences between the two passed values
207
136
  *
208
- * @param itemA - The first item to compare
209
- * @param itemB - The second item to compare
210
- * @param opts - Options for the diff generation
211
- * @param path - Path to the current item
212
- * @param patches - Array of patches to append the results to. Note that this is MUTATED.
213
- * @returns Array of patches
137
+ * @param source - The source value to start off with
138
+ * @param target - The target value that the patch operations will aim to create
139
+ * @param basePath - An optional path that will be prefixed to all subsequent patch operations
140
+ * @returns Array of mutations
214
141
  * @public
215
142
  */
216
- export function diffItem(
217
- itemA: unknown,
218
- itemB: unknown,
219
- opts: DiffOptions = defaultOptions,
143
+ export function diffValue(
144
+ source: unknown,
145
+ target: unknown,
146
+ basePath: Path = [],
147
+ ): SanityPatchOperations[] {
148
+ return serializePatches(diffItem(source, target, basePath))
149
+ }
150
+
151
+ function diffItem(
152
+ source: unknown,
153
+ target: unknown,
220
154
  path: Path = [],
221
155
  patches: Patch[] = [],
222
156
  ): Patch[] {
223
- if (itemA === itemB) {
157
+ if (source === target) {
224
158
  return patches
225
159
  }
226
160
 
227
- const aType = Array.isArray(itemA) ? 'array' : typeof itemA
228
- const bType = Array.isArray(itemB) ? 'array' : typeof itemB
229
-
230
- const aIsUndefined = aType === 'undefined'
231
- const bIsUndefined = bType === 'undefined'
232
-
233
- if (aIsUndefined && !bIsUndefined) {
234
- patches.push({op: 'set', path, value: itemB})
161
+ if (typeof source === 'string' && typeof target === 'string') {
162
+ diffString(source, target, path, patches)
235
163
  return patches
236
164
  }
237
165
 
238
- if (!aIsUndefined && bIsUndefined) {
239
- patches.push({op: 'unset', path})
166
+ if (Array.isArray(source) && Array.isArray(target)) {
167
+ diffArray(source, target, path, patches)
240
168
  return patches
241
169
  }
242
170
 
243
- const options = mergeOptions(opts)
244
- const dataType = aIsUndefined ? bType : aType
245
- const isContainer = dataType === 'object' || dataType === 'array'
246
- if (!isContainer) {
247
- return diffPrimitive(itemA as PrimitiveValue, itemB as PrimitiveValue, options, path, patches)
171
+ if (isRecord(source) && isRecord(target)) {
172
+ diffObject(source, target, path, patches)
173
+ return patches
248
174
  }
249
175
 
250
- if (aType !== bType) {
251
- // Array => Object / Object => Array
252
- patches.push({op: 'set', path, value: itemB})
176
+ if (target === undefined) {
177
+ patches.push({op: 'unset', path})
253
178
  return patches
254
179
  }
255
180
 
256
- return dataType === 'array'
257
- ? diffArray(itemA as unknown[], itemB as unknown[], options, path, patches)
258
- : diffObject(itemA as object, itemB as object, options, path, patches)
181
+ patches.push({op: 'set', path, value: target})
182
+ return patches
259
183
  }
260
184
 
261
185
  function diffObject(
262
- itemA: SanityObject,
263
- itemB: SanityObject,
264
- options: DiffOptions,
186
+ source: Record<string, unknown>,
187
+ target: Record<string, unknown>,
265
188
  path: Path,
266
189
  patches: Patch[],
267
190
  ) {
268
191
  const atRoot = path.length === 0
269
- const aKeys = Object.keys(itemA)
192
+ const aKeys = Object.keys(source)
270
193
  .filter(atRoot ? isNotIgnoredKey : yes)
271
- .map((key) => validateProperty(key, itemA[key], path))
194
+ .map((key) => validateProperty(key, source[key], path))
272
195
 
273
196
  const aKeysLength = aKeys.length
274
- const bKeys = Object.keys(itemB)
197
+ const bKeys = Object.keys(target)
275
198
  .filter(atRoot ? isNotIgnoredKey : yes)
276
- .map((key) => validateProperty(key, itemB[key], path))
199
+ .map((key) => validateProperty(key, target[key], path))
277
200
 
278
201
  const bKeysLength = bKeys.length
279
202
 
280
203
  // Check for deleted items
281
204
  for (let i = 0; i < aKeysLength; i++) {
282
205
  const key = aKeys[i]
283
- if (!(key in itemB)) {
206
+ if (!(key in target)) {
284
207
  patches.push({op: 'unset', path: path.concat(key)})
285
208
  }
286
209
  }
@@ -288,288 +211,458 @@ function diffObject(
288
211
  // Check for changed items
289
212
  for (let i = 0; i < bKeysLength; i++) {
290
213
  const key = bKeys[i]
291
- diffItem(itemA[key], itemB[key], options, path.concat([key]), patches)
214
+ diffItem(source[key], target[key], path.concat([key]), patches)
292
215
  }
293
216
 
294
217
  return patches
295
218
  }
296
219
 
297
- function diffArray(
298
- itemA: unknown[],
299
- itemB: unknown[],
300
- options: DiffOptions,
301
- path: Path,
302
- patches: Patch[],
303
- ) {
220
+ function diffArray(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {
221
+ if (isUniquelyKeyed(source) && isUniquelyKeyed(target)) {
222
+ return diffArrayByKey(source, target, path, patches)
223
+ }
224
+
225
+ return diffArrayByIndex(source, target, path, patches)
226
+ }
227
+
228
+ function diffArrayByIndex(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {
304
229
  // Check for new items
305
- if (itemB.length > itemA.length) {
230
+ if (target.length > source.length) {
306
231
  patches.push({
307
232
  op: 'insert',
308
- after: path.concat([-1]),
309
- items: itemB.slice(itemA.length).map((item, i) => nullifyUndefined(item, path, i, options)),
233
+ position: 'after',
234
+ path: path.concat([-1]),
235
+ items: target.slice(source.length).map(nullifyUndefined),
310
236
  })
311
237
  }
312
238
 
313
239
  // Check for deleted items
314
- if (itemB.length < itemA.length) {
315
- const isSingle = itemA.length - itemB.length === 1
316
- const unsetItems = itemA.slice(itemB.length)
317
-
318
- // If we're revision locked, we can safely unset ranges (eg 5:<end-of-array>).
319
- // Also, if we don't have unique array keys, we can't use any better approach
320
- // than array indexes. If we _do_ have unique array keys, we'll want to unset
321
- // by key, as this is safer in a realtime, collaborative setting
322
- if (isRevisionLocked(options) || !isUniquelyKeyed(unsetItems)) {
240
+ if (target.length < source.length) {
241
+ const isSingle = source.length - target.length === 1
242
+ const unsetItems = source.slice(target.length)
243
+
244
+ // If we have unique array keys, we'll want to unset by key, as this is
245
+ // safer in a realtime, collaborative setting
246
+ if (isUniquelyKeyed(unsetItems)) {
247
+ patches.push(
248
+ ...unsetItems.map((item): UnsetPatch => ({
249
+ op: 'unset',
250
+ path: path.concat({_key: item._key}),
251
+ })),
252
+ )
253
+ } else {
323
254
  patches.push({
324
255
  op: 'unset',
325
- path: path.concat([isSingle ? itemB.length : [itemB.length, '']]),
256
+ path: path.concat([isSingle ? target.length : [target.length, '']]),
326
257
  })
327
- } else {
328
- patches.push(
329
- ...unsetItems.map(
330
- (item): UnsetPatch => ({op: 'unset', path: path.concat({_key: item._key})}),
331
- ),
332
- )
333
258
  }
334
259
  }
335
260
 
336
261
  // Check for illegal array contents
337
- for (let i = 0; i < itemB.length; i++) {
338
- if (Array.isArray(itemB[i])) {
339
- throw new DiffError('Multi-dimensional arrays not supported', path.concat(i), itemB[i])
262
+ for (let i = 0; i < target.length; i++) {
263
+ if (Array.isArray(target[i])) {
264
+ throw new DiffError('Multi-dimensional arrays not supported', path.concat(i), target[i])
340
265
  }
341
266
  }
342
267
 
343
- const overlapping = Math.min(itemA.length, itemB.length)
344
- const segmentA = itemA.slice(0, overlapping)
345
- const segmentB = itemB.slice(0, overlapping)
268
+ const overlapping = Math.min(source.length, target.length)
269
+ const segmentA = source.slice(0, overlapping)
270
+ const segmentB = target.slice(0, overlapping)
346
271
 
347
- return isUniquelyKeyed(segmentA) && isUniquelyKeyed(segmentB)
348
- ? diffArrayByKey(segmentA, segmentB, options, path, patches)
349
- : diffArrayByIndex(segmentA, segmentB, options, path, patches)
350
- }
351
-
352
- function diffArrayByIndex(
353
- itemA: unknown[],
354
- itemB: unknown[],
355
- options: DiffOptions,
356
- path: Path,
357
- patches: Patch[],
358
- ) {
359
- for (let i = 0; i < itemA.length; i++) {
360
- diffItem(
361
- itemA[i],
362
- nullifyUndefined(itemB[i], path, i, options),
363
- options,
364
- path.concat(i),
365
- patches,
366
- )
272
+ for (let i = 0; i < segmentA.length; i++) {
273
+ diffItem(segmentA[i], nullifyUndefined(segmentB[i]), path.concat(i), patches)
367
274
  }
368
275
 
369
276
  return patches
370
277
  }
371
278
 
279
+ /**
280
+ * Diffs two arrays of keyed objects by their `_key` properties.
281
+ *
282
+ * This approach is preferred over index-based diffing for collaborative editing scenarios
283
+ * because it generates patches that are more resilient to concurrent modifications.
284
+ * When multiple users edit the same array simultaneously, key-based patches have better
285
+ * conflict resolution characteristics than index-based patches.
286
+ *
287
+ * The function handles three main operations:
288
+ * 1. **Reordering**: When existing items change positions within the array
289
+ * 2. **Content changes**: When the content of existing items is modified
290
+ * 3. **Insertions/Deletions**: When items are added or removed from the array
291
+ *
292
+ * @param source - The original array with keyed objects
293
+ * @param target - The target array with keyed objects
294
+ * @param path - The path to this array within the document
295
+ * @param patches - Array to accumulate generated patches
296
+ * @returns The patches array with new patches appended
297
+ */
372
298
  function diffArrayByKey(
373
- itemA: KeyedSanityObject[],
374
- itemB: KeyedSanityObject[],
375
- options: DiffOptions,
299
+ source: KeyedSanityObject[],
300
+ target: KeyedSanityObject[],
376
301
  path: Path,
377
302
  patches: Patch[],
378
303
  ) {
379
- const keyedA = indexByKey(itemA)
380
- const keyedB = indexByKey(itemB)
304
+ // Create lookup maps for efficient key-based access to array items
305
+ const sourceItemsByKey = new Map(source.map((item) => [item._key, item]))
306
+ const targetItemsByKey = new Map(target.map((item) => [item._key, item]))
307
+
308
+ // Categorize keys by their presence in source vs target arrays
309
+ const sourceKeys = new Set(sourceItemsByKey.keys())
310
+ const targetKeys = new Set(targetItemsByKey.keys())
311
+ const keysRemovedFromSource = difference(sourceKeys, targetKeys)
312
+ const keysAddedToTarget = difference(targetKeys, sourceKeys)
313
+ const keysInBothArrays = intersection(sourceKeys, targetKeys)
314
+
315
+ // Handle reordering of existing items within the array.
316
+ // We detect reordering by comparing the relative positions of keys that exist in both arrays,
317
+ // excluding keys that were added or removed (since they don't participate in reordering).
318
+ const sourceKeysStillPresent = Array.from(difference(sourceKeys, keysRemovedFromSource))
319
+ const targetKeysAlreadyPresent = Array.from(difference(targetKeys, keysAddedToTarget))
320
+
321
+ // Track which keys need to be reordered by comparing their relative positions
322
+ const keyReorderOperations: {sourceKey: string; targetKey: string}[] = []
323
+
324
+ for (let i = 0; i < keysInBothArrays.size; i++) {
325
+ const keyAtPositionInSource = sourceKeysStillPresent[i]
326
+ const keyAtPositionInTarget = targetKeysAlreadyPresent[i]
327
+
328
+ // If different keys occupy the same relative position, a reorder is needed
329
+ if (keyAtPositionInSource !== keyAtPositionInTarget) {
330
+ keyReorderOperations.push({
331
+ sourceKey: keyAtPositionInSource,
332
+ targetKey: keyAtPositionInTarget,
333
+ })
334
+ }
335
+ }
336
+
337
+ // Generate reorder patch if any items changed positions
338
+ if (keyReorderOperations.length) {
339
+ patches.push({
340
+ op: 'reorder',
341
+ path,
342
+ snapshot: source,
343
+ reorders: keyReorderOperations,
344
+ })
345
+ }
346
+
347
+ // Process content changes for items that exist in both arrays
348
+ for (const key of keysInBothArrays) {
349
+ diffItem(sourceItemsByKey.get(key), targetItemsByKey.get(key), [...path, {_key: key}], patches)
350
+ }
381
351
 
382
- // There's a bunch of hard/semi-hard problems related to using keys
383
- // Unless we have the exact same order, just use indexes for now
384
- if (!arrayIsEqual(keyedA.keys, keyedB.keys)) {
385
- return diffArrayByIndex(itemA, itemB, options, path, patches)
352
+ // Remove items that no longer exist in the target array
353
+ for (const keyToRemove of keysRemovedFromSource) {
354
+ patches.push({op: 'unset', path: [...path, {_key: keyToRemove}]})
386
355
  }
387
356
 
388
- for (let i = 0; i < keyedB.keys.length; i++) {
389
- const key = keyedB.keys[i]
390
- const valueA = keyedA.index[key]
391
- const valueB = nullifyUndefined(keyedB.index[key], path, i, options)
392
- diffItem(valueA, valueB, options, path.concat({_key: key}), patches)
357
+ // Insert new items that were added to the target array
358
+ // We batch consecutive insertions for efficiency and insert them at the correct positions
359
+ if (keysAddedToTarget.size) {
360
+ let insertionAnchorKey: string // The key after which we'll insert pending items
361
+ let itemsPendingInsertion: unknown[] = []
362
+
363
+ const flushPendingInsertions = () => {
364
+ if (itemsPendingInsertion.length) {
365
+ patches.push({
366
+ op: 'insert',
367
+ // Insert after the anchor key if we have one, otherwise insert at the beginning
368
+ ...(insertionAnchorKey
369
+ ? {position: 'after', path: [...path, {_key: insertionAnchorKey}]}
370
+ : {position: 'before', path: [...path, 0]}),
371
+ items: itemsPendingInsertion,
372
+ })
373
+ }
374
+ }
375
+
376
+ // Walk through the target array to determine where new items should be inserted
377
+ for (const key of targetKeys) {
378
+ if (keysAddedToTarget.has(key)) {
379
+ // This is a new item - add it to the pending insertion batch
380
+ itemsPendingInsertion.push(targetItemsByKey.get(key)!)
381
+ } else if (keysInBothArrays.has(key)) {
382
+ // This is an existing item - flush any pending insertions before it
383
+ flushPendingInsertions()
384
+ insertionAnchorKey = key
385
+ itemsPendingInsertion = []
386
+ }
387
+ }
388
+
389
+ // Flush any remaining insertions at the end
390
+ flushPendingInsertions()
393
391
  }
394
392
 
395
393
  return patches
396
394
  }
397
395
 
398
- function getDiffMatchPatch(
399
- itemA: PrimitiveValue,
400
- itemB: PrimitiveValue,
401
- options: DiffOptions,
402
- path: Path,
403
- ): DiffMatchPatch | undefined {
404
- const {enabled, lengthThresholdRelative, lengthThresholdAbsolute} = options.diffMatchPatch
405
- const segment = path[path.length - 1]
406
- if (
407
- !enabled ||
408
- // Don't use for anything but strings
409
- typeof itemA !== 'string' ||
410
- typeof itemB !== 'string' ||
411
- // Don't use for `_key`, `_ref` etc
412
- (typeof segment === 'string' && segment[0] === '_') ||
413
- // Don't use on short strings
414
- itemB.length < lengthThresholdAbsolute
415
- ) {
416
- return undefined
396
+ /**
397
+ * Determines whether to use diff-match-patch or fallback to a `set` operation
398
+ * when creating a patch to transform a `source` string to `target` string.
399
+ *
400
+ * `diffMatchPatch` patches are typically preferred to `set` operations because
401
+ * they handle conflicts better (when multiple editors work simultaneously) by
402
+ * preserving the user's intended and allowing for 3-way merges.
403
+ *
404
+ * **Heuristic rationale:**
405
+ *
406
+ * Perf analysis revealed that string length has minimal impact on small,
407
+ * keystroke-level changes, but large text replacements (high change ratio) can
408
+ * trigger worst-case algorithm behavior. The 40% change ratio threshold is a
409
+ * simple heuristic that catches problematic replacement scenarios while
410
+ * allowing the algorithm to excel at insertions and deletions.
411
+ *
412
+ * **Performance characteristics (tested on M2 MacBook Pro):**
413
+ *
414
+ * *Keystroke-level editing (most common use case):*
415
+ * - Small strings (1KB-10KB): 0ms for 1-5 keystrokes, consistently sub-millisecond
416
+ * - Medium strings (50KB-200KB): 0ms for 1-5 keystrokes, consistently sub-millisecond
417
+ * - 10 simultaneous keystrokes: ~12ms on 100KB strings
418
+ *
419
+ * *Copy-paste operations (less frequent):*
420
+ * - Small copy-paste operations (<50KB): 0-10ms regardless of string length
421
+ * - Large insertions/deletions (50KB+): 0-50ms (excellent performance)
422
+ * - Large text replacements (50KB+): 70ms-2s+ (can be slow due to algorithm complexity)
423
+ *
424
+ * **Algorithm details:**
425
+ * Uses Myers' diff algorithm with O(ND) time complexity where N=text length and D=edit distance.
426
+ * Includes optimizations: common prefix/suffix removal, line-mode processing, and timeout protection.
427
+ *
428
+ *
429
+ * **Test methodology:**
430
+ * - Generated realistic word-based text patterns
431
+ * - Simulated actual editing behaviors (keystrokes vs copy-paste)
432
+ * - Measured performance across string sizes from 1KB to 10MB
433
+ * - Validated against edge cases including repetitive text and scattered changes
434
+ *
435
+ * @param source - The previous version of the text
436
+ * @param target - The new version of the text
437
+ * @returns true if diff-match-patch should be used, false if fallback to set operation
438
+ *
439
+ * @example
440
+ * ```typescript
441
+ * // Keystroke editing - always fast
442
+ * shouldUseDiffMatchPatch(largeDoc, largeDocWithTypo) // true, ~0ms
443
+ *
444
+ * // Small paste - always fast
445
+ * shouldUseDiffMatchPatch(doc, docWithSmallInsertion) // true, ~0ms
446
+ *
447
+ * // Large replacement - potentially slow
448
+ * shouldUseDiffMatchPatch(article, completelyDifferentArticle) // false, use set
449
+ * ```
450
+ *
451
+ * Compatible with @sanity/diff-match-patch@3.2.0
452
+ */
453
+ function shouldUseDiffMatchPatch(source: string, target: string): boolean {
454
+ const maxLength = Math.max(source.length, target.length)
455
+
456
+ // Always reject strings larger than our tested size limit
457
+ if (maxLength > DMP_MAX_STRING_SIZE) {
458
+ return false
417
459
  }
418
460
 
419
- let strPatch = ''
420
- try {
421
- const patch = makeDiff(itemA, itemB)
422
- const diff = cleanupEfficiency(patch)
423
- strPatch = stringifyPatches(makePatches(diff))
424
- } catch (err) {
425
- // Fall back to using regular set patch
426
- return undefined
461
+ // For small strings, always use diff-match-patch regardless of change ratio
462
+ // Performance testing showed these are always fast (<10ms)
463
+ if (maxLength < DMP_MIN_SIZE_FOR_RATIO_CHECK) {
464
+ return true
465
+ }
466
+
467
+ // Calculate the change ratio to detect large text replacements
468
+ // High ratios indicate replacement scenarios which can trigger slow algorithm paths
469
+ const lengthDifference = Math.abs(target.length - source.length)
470
+ const changeRatio = lengthDifference / maxLength
471
+
472
+ // If change ratio is high, likely a replacement operation that could be slow
473
+ // Fall back to set operation for better user experience
474
+ if (changeRatio > DMP_MAX_STRING_LENGTH_CHANGE_RATIO) {
475
+ return false
427
476
  }
428
477
 
429
- // Don't use patch if it's longer than allowed relative threshold.
430
- // Allow a 120 character patch for a 100 character string,
431
- // but don't allow a 800 character patch for a 500 character value.
432
- return strPatch.length > itemB.length * lengthThresholdRelative
433
- ? undefined
434
- : {op: 'diffMatchPatch', path, value: strPatch}
478
+ // All other cases: use diff-match-patch
479
+ // This covers keystroke editing and insertion/deletion scenarios which perform excellently
480
+ return true
435
481
  }
436
482
 
437
- function diffPrimitive(
438
- itemA: PrimitiveValue,
439
- itemB: PrimitiveValue,
440
- options: DiffOptions,
441
- path: Path,
442
- patches: Patch[],
443
- ): Patch[] {
444
- const dmp = getDiffMatchPatch(itemA, itemB, options, path)
483
+ function getDiffMatchPatch(source: string, target: string, path: Path): DiffMatchPatch | undefined {
484
+ const last = path.at(-1)
485
+ // don't use diff-match-patch for system keys
486
+ if (typeof last === 'string' && last.startsWith('_')) return undefined
487
+ if (!shouldUseDiffMatchPatch(source, target)) return undefined
445
488
 
446
- patches.push(
447
- dmp || {
448
- op: 'set',
449
- path,
450
- value: itemB,
451
- },
452
- )
489
+ try {
490
+ // Using `makePatches(string, string)` directly instead of the multi-step approach e.g.
491
+ // `stringifyPatches(makePatches(cleanupEfficiency(makeDiff(source, target))))`.
492
+ // this is because `makePatches` internally handles diff generation and
493
+ // automatically applies both `cleanupSemantic()` and `cleanupEfficiency()`
494
+ // when beneficial, resulting in cleaner code with near identical performance and
495
+ // better error handling.
496
+ // [source](https://github.com/sanity-io/diff-match-patch/blob/v3.2.0/src/patch/make.ts#L67-L76)
497
+ //
498
+ // Performance validation (M2 MacBook Pro):
499
+ // Both approaches measured at identical performance:
500
+ // - 10KB strings: 0-1ms total processing time
501
+ // - 100KB strings: 0-1ms total processing time
502
+ // - Individual step breakdown: makeDiff(0ms) + cleanup(0ms) + makePatches(0ms) + stringify(~1ms)
503
+ const strPatch = stringifyPatches(makePatches(source, target))
504
+ return {op: 'diffMatchPatch', path, value: strPatch}
505
+ } catch {
506
+ // Fall back to using regular set patch
507
+ return undefined
508
+ }
509
+ }
453
510
 
511
+ function diffString(source: string, target: string, path: Path, patches: Patch[]) {
512
+ const dmp = getDiffMatchPatch(source, target, path)
513
+ patches.push(dmp ?? {op: 'set', path, value: target})
454
514
  return patches
455
515
  }
456
516
 
457
517
  function isNotIgnoredKey(key: string) {
458
- return ignoredKeys.indexOf(key) === -1
518
+ return SYSTEM_KEYS.indexOf(key) === -1
459
519
  }
460
520
 
461
- function serializePatches(
462
- patches: Patch[],
463
- options: {id: string; ifRevisionID?: string},
464
- ): SanityPatchMutation[] {
465
- if (patches.length === 0) {
466
- return []
467
- }
521
+ // mutually exclusive operations
522
+ type SanityPatchOperation =
523
+ | SanitySetPatchOperation
524
+ | SanityUnsetPatchOperation
525
+ | SanityInsertPatchOperation
526
+ | SanityDiffMatchPatchOperation
468
527
 
469
- const {id, ifRevisionID} = options
470
- const set = patches.filter((patch): patch is SetPatch => patch.op === 'set')
471
- const unset = patches.filter((patch): patch is UnsetPatch => patch.op === 'unset')
472
- const insert = patches.filter((patch): patch is InsertAfterPatch => patch.op === 'insert')
473
- const dmp = patches.filter((patch): patch is DiffMatchPatch => patch.op === 'diffMatchPatch')
474
-
475
- const withSet =
476
- set.length > 0 &&
477
- set.reduce(
478
- (patch: SanitySetPatch, item: SetPatch) => {
479
- const path = pathToString(item.path)
480
- patch.set[path] = item.value
481
- return patch
482
- },
483
- {id, set: {}},
484
- )
528
+ function serializePatches(patches: Patch[], curr?: SanityPatchOperation): SanityPatchOperations[] {
529
+ const [patch, ...rest] = patches
530
+ if (!patch) return curr ? [curr] : []
485
531
 
486
- const withUnset =
487
- unset.length > 0 &&
488
- unset.reduce(
489
- (patch: SanityUnsetPatch, item: UnsetPatch) => {
490
- const path = pathToString(item.path)
491
- patch.unset.push(path)
492
- return patch
493
- },
494
- {id, unset: []},
495
- )
532
+ switch (patch.op) {
533
+ case 'set':
534
+ case 'diffMatchPatch': {
535
+ type CurrentOp = Extract<SanityPatchOperation, {[K in typeof patch.op]: {}}>
536
+ const emptyOp = {[patch.op]: {}} as CurrentOp
496
537
 
497
- const withInsert = insert.reduce((acc: SanityInsertPatch[], item: InsertAfterPatch) => {
498
- const after = pathToString(item.after)
499
- return acc.concat({id, insert: {after, items: item.items}})
500
- }, [])
501
-
502
- const withDmp =
503
- dmp.length > 0 &&
504
- dmp.reduce(
505
- (patch: SanityDiffMatchPatch, item: DiffMatchPatch) => {
506
- const path = pathToString(item.path)
507
- patch.diffMatchPatch[path] = item.value
508
- return patch
509
- },
510
- {id, diffMatchPatch: {}},
511
- )
538
+ if (!curr) return serializePatches(patches, emptyOp)
539
+ if (!(patch.op in curr)) return [curr, ...serializePatches(patches, emptyOp)]
512
540
 
513
- const patchSet: SanityPatch[] = [withUnset, withSet, withDmp, ...withInsert].filter(
514
- (item): item is SanityPatch => item !== false,
515
- )
541
+ Object.assign((curr as CurrentOp)[patch.op], {[pathToString(patch.path)]: patch.value})
542
+ return serializePatches(rest, curr)
543
+ }
544
+ case 'unset': {
545
+ const emptyOp = {unset: []}
546
+ if (!curr) return serializePatches(patches, emptyOp)
547
+ if (!('unset' in curr)) return [curr, ...serializePatches(patches, emptyOp)]
516
548
 
517
- return patchSet.map((patch, i) => ({
518
- patch: ifRevisionID && i === 0 ? {...patch, ifRevisionID} : patch,
519
- }))
549
+ curr.unset.push(pathToString(patch.path))
550
+ return serializePatches(rest, curr)
551
+ }
552
+ case 'insert': {
553
+ if (curr) return [curr, ...serializePatches(patches)]
554
+
555
+ return [
556
+ {
557
+ insert: {
558
+ [patch.position]: pathToString(patch.path),
559
+ items: patch.items,
560
+ },
561
+ } as SanityInsertPatchOperation,
562
+ ...serializePatches(rest),
563
+ ]
564
+ }
565
+ case 'reorder': {
566
+ if (curr) return [curr, ...serializePatches(patches)]
567
+
568
+ // REORDER STRATEGY: Two-phase approach to avoid key collisions
569
+ //
570
+ // Problem: Direct key swaps can cause collisions. For example, swapping A↔B:
571
+ // - Set A's content to B: ✓
572
+ // - Set B's content to A: ✗ (A's content was already overwritten)
573
+ //
574
+ // Solution: Use temporary keys as an intermediate step
575
+ // Phase 1: Move all items to temporary keys with their final content
576
+ // Phase 2: Update just the _key property to restore the final keys
577
+
578
+ // Phase 1: Move items to collision-safe temporary keys
579
+ const tempKeyOperations: SanityPatchOperations = {}
580
+ tempKeyOperations.set = {}
581
+
582
+ for (const {sourceKey, targetKey} of patch.reorders) {
583
+ const temporaryKey = `__temp_reorder_${sourceKey}__`
584
+ const finalContentForThisPosition =
585
+ patch.snapshot[getIndexForKey(patch.snapshot, targetKey)]
586
+
587
+ Object.assign(tempKeyOperations.set, {
588
+ [pathToString([...patch.path, {_key: sourceKey}])]: {
589
+ ...finalContentForThisPosition,
590
+ _key: temporaryKey,
591
+ },
592
+ })
593
+ }
594
+
595
+ // Phase 2: Update _key properties to restore the intended final keys
596
+ const finalKeyOperations: SanityPatchOperations = {}
597
+ finalKeyOperations.set = {}
598
+
599
+ for (const {sourceKey, targetKey} of patch.reorders) {
600
+ const temporaryKey = `__temp_reorder_${sourceKey}__`
601
+
602
+ Object.assign(finalKeyOperations.set, {
603
+ [pathToString([...patch.path, {_key: temporaryKey}, '_key'])]: targetKey,
604
+ })
605
+ }
606
+
607
+ return [tempKeyOperations, finalKeyOperations, ...serializePatches(rest)]
608
+ }
609
+ default: {
610
+ return []
611
+ }
612
+ }
520
613
  }
521
614
 
522
615
  function isUniquelyKeyed(arr: unknown[]): arr is KeyedSanityObject[] {
523
- const keys = []
616
+ const seenKeys = new Set<string>()
524
617
 
525
- for (let i = 0; i < arr.length; i++) {
526
- const key = getKey(arr[i])
527
- if (!key || keys.indexOf(key) !== -1) {
528
- return false
529
- }
618
+ for (const item of arr) {
619
+ // Each item must be a keyed object with a _key property
620
+ if (!isKeyedObject(item)) return false
621
+
622
+ // Each _key must be unique within the array
623
+ if (seenKeys.has(item._key)) return false
530
624
 
531
- keys.push(key)
625
+ seenKeys.add(item._key)
532
626
  }
533
627
 
534
628
  return true
535
629
  }
536
630
 
537
- function getKey(obj: unknown) {
538
- return typeof obj === 'object' && obj !== null && (obj as KeyedSanityObject)._key
539
- }
631
+ // Cache to avoid recomputing key-to-index mappings for the same array
632
+ const keyToIndexCache = new WeakMap<KeyedSanityObject[], Record<string, number>>()
633
+
634
+ function getIndexForKey(keyedArray: KeyedSanityObject[], targetKey: string) {
635
+ const cachedMapping = keyToIndexCache.get(keyedArray)
636
+ if (cachedMapping) return cachedMapping[targetKey]
540
637
 
541
- function indexByKey(arr: KeyedSanityObject[]) {
542
- return arr.reduce(
543
- (acc, item) => {
544
- acc.keys.push(item._key)
545
- acc.index[item._key] = item
546
- return acc
638
+ // Build a mapping from _key to array index
639
+ const keyToIndexMapping = keyedArray.reduce<Record<string, number>>(
640
+ (mapping, {_key}, arrayIndex) => {
641
+ mapping[_key] = arrayIndex
642
+ return mapping
547
643
  },
548
- {keys: [] as string[], index: {} as {[key: string]: KeyedSanityObject}},
644
+ {},
549
645
  )
550
- }
551
-
552
- function arrayIsEqual(itemA: unknown[], itemB: unknown[]) {
553
- return itemA.length === itemB.length && itemA.every((item, i) => itemB[i] === item)
554
- }
555
646
 
556
- function nullifyUndefined(item: unknown, path: Path, index: number, options: PatchOptions) {
557
- if (typeof item !== 'undefined') {
558
- return item
559
- }
647
+ keyToIndexCache.set(keyedArray, keyToIndexMapping)
560
648
 
561
- if (!options.hideWarnings) {
562
- const serializedPath = pathToString(path.concat(index))
563
- console.warn(`undefined value in array converted to null (at '${serializedPath}')`)
564
- }
649
+ return keyToIndexMapping[targetKey]
650
+ }
565
651
 
566
- return null
652
+ function isRecord(value: unknown): value is Record<string, unknown> {
653
+ return typeof value === 'object' && !!value && !Array.isArray(value)
567
654
  }
568
655
 
569
- function isRevisionLocked(options: PatchOptions): boolean {
570
- return Boolean(options.ifRevisionID)
656
+ /**
657
+ * Simplify returns `null` if the value given was `undefined`. This behavior
658
+ * is the same as how `JSON.stringify` works so this is relatively expected
659
+ * behavior.
660
+ */
661
+ function nullifyUndefined(item: unknown) {
662
+ if (item === undefined) return null
663
+ return item
571
664
  }
572
665
 
573
- function yes(_: unknown) {
666
+ function yes() {
574
667
  return true
575
668
  }