mobx-keystone-loro 1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/dist/mobx-keystone-loro.esm.js +957 -0
  5. package/dist/mobx-keystone-loro.esm.mjs +957 -0
  6. package/dist/mobx-keystone-loro.umd.js +957 -0
  7. package/dist/types/binding/LoroTextModel.d.ts +72 -0
  8. package/dist/types/binding/applyLoroEventToMobx.d.ts +9 -0
  9. package/dist/types/binding/applyMobxChangeToLoroObject.d.ts +7 -0
  10. package/dist/types/binding/bindLoroToMobxKeystone.d.ts +33 -0
  11. package/dist/types/binding/convertJsonToLoroData.d.ts +51 -0
  12. package/dist/types/binding/convertLoroDataToJson.d.ts +11 -0
  13. package/dist/types/binding/loroBindingContext.d.ts +39 -0
  14. package/dist/types/binding/loroSnapshotTracking.d.ts +26 -0
  15. package/dist/types/binding/moveWithinArray.d.ts +36 -0
  16. package/dist/types/binding/resolveLoroPath.d.ts +11 -0
  17. package/dist/types/index.d.ts +8 -0
  18. package/dist/types/plainTypes.d.ts +6 -0
  19. package/dist/types/utils/error.d.ts +4 -0
  20. package/dist/types/utils/getOrCreateLoroCollectionAtom.d.ts +7 -0
  21. package/dist/types/utils/isBindableLoroContainer.d.ts +9 -0
  22. package/package.json +92 -0
  23. package/src/binding/LoroTextModel.ts +211 -0
  24. package/src/binding/applyLoroEventToMobx.ts +280 -0
  25. package/src/binding/applyMobxChangeToLoroObject.ts +182 -0
  26. package/src/binding/bindLoroToMobxKeystone.ts +353 -0
  27. package/src/binding/convertJsonToLoroData.ts +315 -0
  28. package/src/binding/convertLoroDataToJson.ts +68 -0
  29. package/src/binding/loroBindingContext.ts +46 -0
  30. package/src/binding/loroSnapshotTracking.ts +36 -0
  31. package/src/binding/moveWithinArray.ts +112 -0
  32. package/src/binding/resolveLoroPath.ts +37 -0
  33. package/src/index.ts +16 -0
  34. package/src/plainTypes.ts +7 -0
  35. package/src/utils/error.ts +12 -0
  36. package/src/utils/getOrCreateLoroCollectionAtom.ts +17 -0
  37. package/src/utils/isBindableLoroContainer.ts +13 -0
@@ -0,0 +1,353 @@
1
+ import {
2
+ type ContainerID,
3
+ type LoroDoc,
4
+ type LoroEventBatch,
5
+ LoroMap,
6
+ LoroMovableList,
7
+ LoroText,
8
+ } from "loro-crdt"
9
+ import { action } from "mobx"
10
+ import {
11
+ AnyDataModel,
12
+ AnyModel,
13
+ AnyStandardType,
14
+ DeepChange,
15
+ DeepChangeType,
16
+ fromSnapshot,
17
+ getParentToChildPath,
18
+ getSnapshot,
19
+ isTreeNode,
20
+ ModelClass,
21
+ onDeepChange,
22
+ onGlobalDeepChange,
23
+ onSnapshot,
24
+ resolvePath,
25
+ SnapshotOutOf,
26
+ TypeToData,
27
+ } from "mobx-keystone"
28
+ import { nanoid } from "nanoid"
29
+ import type { PlainArray, PlainObject } from "../plainTypes"
30
+ import { getOrCreateLoroCollectionAtom } from "../utils/getOrCreateLoroCollectionAtom"
31
+ import type { BindableLoroContainer } from "../utils/isBindableLoroContainer"
32
+
33
+ import { applyLoroEventToMobx, ReconciliationMap } from "./applyLoroEventToMobx"
34
+ import { applyMobxChangeToLoroObject } from "./applyMobxChangeToLoroObject"
35
+ import {
36
+ applyDeltaToLoroText,
37
+ applyJsonArrayToLoroMovableList,
38
+ applyJsonObjectToLoroMap,
39
+ extractTextDeltaFromSnapshot,
40
+ } from "./convertJsonToLoroData"
41
+ import { convertLoroDataToJson } from "./convertLoroDataToJson"
42
+ import { loroTextModelId } from "./LoroTextModel"
43
+ import { type LoroBindingContext, loroBindingContext } from "./loroBindingContext"
44
+ import { setLoroContainerSnapshot } from "./loroSnapshotTracking"
45
+ import {
46
+ type ArrayMoveChange,
47
+ isInMoveContextForArray,
48
+ processChangeForMove,
49
+ } from "./moveWithinArray"
50
+
51
+ /**
52
+ * Captures snapshots of tree nodes in a DeepChange.
53
+ * This ensures values are captured at change time, not at apply time,
54
+ * preventing issues when values are mutated after being added to a collection.
55
+ */
56
+ function captureChangeSnapshots(change: DeepChange): DeepChange {
57
+ if (change.type === DeepChangeType.ArraySplice && change.addedValues.length > 0) {
58
+ const snapshots = change.addedValues.map((v) => (isTreeNode(v) ? getSnapshot(v) : v))
59
+ return { ...change, addedValues: snapshots }
60
+ } else if (change.type === DeepChangeType.ArrayUpdate) {
61
+ const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
62
+ return { ...change, newValue: snapshot }
63
+ } else if (
64
+ change.type === DeepChangeType.ObjectAdd ||
65
+ change.type === DeepChangeType.ObjectUpdate
66
+ ) {
67
+ const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
68
+ return { ...change, newValue: snapshot }
69
+ }
70
+ return change
71
+ }
72
+
73
+ /**
74
+ * Creates a bidirectional binding between a Loro data structure and a mobx-keystone model.
75
+ */
76
+ export function bindLoroToMobxKeystone<
77
+ TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
78
+ >({
79
+ loroDoc,
80
+ loroObject,
81
+ mobxKeystoneType,
82
+ }: {
83
+ /**
84
+ * The Loro document.
85
+ */
86
+ loroDoc: LoroDoc
87
+ /**
88
+ * The bound Loro data structure.
89
+ */
90
+ loroObject: BindableLoroContainer
91
+ /**
92
+ * The mobx-keystone model type.
93
+ */
94
+ mobxKeystoneType: TType
95
+ }): {
96
+ /**
97
+ * The bound mobx-keystone instance.
98
+ */
99
+ boundObject: TypeToData<TType>
100
+ /**
101
+ * Disposes the binding.
102
+ */
103
+ dispose: () => void
104
+ /**
105
+ * The Loro origin string used for binding transactions.
106
+ */
107
+ loroOrigin: string
108
+ } {
109
+ const loroOrigin = `bindLoroToMobxKeystoneTransactionOrigin-${nanoid()}`
110
+
111
+ let applyingLoroChangesToMobxKeystone = 0
112
+
113
+ const bindingContext: LoroBindingContext = {
114
+ loroDoc,
115
+ loroObject,
116
+ mobxKeystoneType,
117
+ loroOrigin,
118
+ boundObject: undefined, // not yet created
119
+
120
+ get isApplyingLoroChangesToMobxKeystone() {
121
+ return applyingLoroChangesToMobxKeystone > 0
122
+ },
123
+ }
124
+
125
+ const loroJson = convertLoroDataToJson(loroObject) as SnapshotOutOf<TypeToData<TType>>
126
+
127
+ let boundObject: TypeToData<TType>
128
+
129
+ // Track if any init changes occurred during fromSnapshot
130
+ // If they did, we need to sync the model snapshot to the CRDT
131
+ let hasInitChanges = false
132
+
133
+ const createBoundObject = () => {
134
+ // Set up a temporary global listener to detect init changes during fromSnapshot
135
+ const disposeGlobalListener = onGlobalDeepChange((_target, change) => {
136
+ if (change.isInit) {
137
+ hasInitChanges = true
138
+ }
139
+ })
140
+
141
+ try {
142
+ const result = loroBindingContext.apply(
143
+ () => fromSnapshot(mobxKeystoneType, loroJson),
144
+ bindingContext
145
+ )
146
+ loroBindingContext.set(result, { ...bindingContext, boundObject: result })
147
+ return result
148
+ } finally {
149
+ disposeGlobalListener()
150
+ }
151
+ }
152
+
153
+ boundObject = createBoundObject()
154
+
155
+ // Get the path to the root Loro object for path resolution
156
+ const rootLoroPath = loroDoc.getPathToContainer(loroObject.id) ?? []
157
+
158
+ // bind any changes from Loro to mobx-keystone
159
+ const loroSubscribeCb = action((eventBatch: LoroEventBatch) => {
160
+ // Skip changes that originated from this binding
161
+ if (eventBatch.origin === loroOrigin) {
162
+ return
163
+ }
164
+
165
+ // Track newly inserted containers to avoid double-processing their events
166
+ const newlyInsertedContainers = new Set<ContainerID>()
167
+
168
+ // Create a map to store reconciliation data for this batch
169
+ const reconciliationMap: ReconciliationMap = new Map()
170
+
171
+ // Collect init changes that occur during event application
172
+ // (e.g., fromSnapshot calls that trigger onInit hooks)
173
+ // We store both target and change so we can compute the correct path later
174
+ // Snapshots are captured immediately to preserve values at init time
175
+ const initChanges: { target: object; change: DeepChange }[] = []
176
+ const disposeGlobalListener = onGlobalDeepChange((target, change) => {
177
+ if (change.isInit) {
178
+ initChanges.push({ target, change: captureChangeSnapshots(change) })
179
+ }
180
+ })
181
+
182
+ applyingLoroChangesToMobxKeystone++
183
+ try {
184
+ try {
185
+ for (const event of eventBatch.events) {
186
+ applyLoroEventToMobx(
187
+ event,
188
+ loroDoc,
189
+ boundObject,
190
+ rootLoroPath,
191
+ reconciliationMap,
192
+ newlyInsertedContainers
193
+ )
194
+ }
195
+ } finally {
196
+ disposeGlobalListener()
197
+ }
198
+
199
+ // Sync back any init-time mutations from fromSnapshot calls
200
+ // (e.g., onInit hooks that modify the model)
201
+ // This is needed because init changes during Loro event handling are not
202
+ // captured by the main onDeepChange (it skips changes when applyingLoroChangesToMobxKeystone > 0)
203
+ if (initChanges.length > 0) {
204
+ loroDoc.setNextCommitOrigin(loroOrigin)
205
+
206
+ for (const { target, change } of initChanges) {
207
+ // Compute the path from boundObject to the target
208
+ const pathToTarget = getParentToChildPath(boundObject, target)
209
+ if (pathToTarget !== undefined) {
210
+ // Create a new change with the correct path from the root
211
+ const changeWithCorrectPath: DeepChange = {
212
+ ...change,
213
+ path: [...pathToTarget, ...change.path],
214
+ }
215
+ applyMobxChangeToLoroObject(changeWithCorrectPath, loroObject)
216
+ }
217
+ }
218
+
219
+ loroDoc.commit()
220
+ }
221
+
222
+ // Update snapshot tracking: the Loro container is now in sync with the current MobX snapshot
223
+ // This enables the merge optimization to skip unchanged subtrees during reconciliation
224
+ if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
225
+ setLoroContainerSnapshot(loroObject, getSnapshot(boundObject))
226
+ }
227
+ } finally {
228
+ applyingLoroChangesToMobxKeystone--
229
+ }
230
+ })
231
+ const loroUnsubscribe = loroDoc.subscribe(loroSubscribeCb)
232
+
233
+ // bind any changes from mobx-keystone to Loro
234
+ // Collect changes during an action and apply them after the action completes
235
+ let pendingChanges: (DeepChange | ArrayMoveChange)[] = []
236
+
237
+ const disposeOnDeepChange = onDeepChange(boundObject, (change) => {
238
+ // Skip if we're currently applying Loro changes to MobX
239
+ if (bindingContext.isApplyingLoroChangesToMobxKeystone) {
240
+ return
241
+ }
242
+
243
+ // Skip init changes - they are handled by the getSnapshot + merge at the end of binding
244
+ if (change.isInit) {
245
+ return
246
+ }
247
+
248
+ // Check if this is part of a moveWithinArray operation
249
+ if (change.type === DeepChangeType.ArraySplice) {
250
+ const resolved = resolvePath(boundObject, change.path)
251
+ if (resolved.resolved && isInMoveContextForArray(resolved.value as unknown[])) {
252
+ const moveResult = processChangeForMove(change)
253
+
254
+ if (moveResult === undefined) {
255
+ // Intercepted but move not complete - skip this change
256
+ return
257
+ }
258
+
259
+ // Move complete - add the move change instead
260
+ pendingChanges.push(moveResult)
261
+ return
262
+ }
263
+ }
264
+
265
+ // Capture snapshots now before the values can be mutated within the same transaction.
266
+ // This is necessary because changes are collected and applied after the action completes,
267
+ // by which time the original values may have been modified.
268
+ // Example: `obj.items = [a, b]; obj.items.splice(0, 1)` - without early capture,
269
+ // the ObjectUpdate for `items` would get the post-splice array state.
270
+ pendingChanges.push(captureChangeSnapshots(change))
271
+ })
272
+
273
+ // Apply collected changes when snapshot changes (i.e., after action completes)
274
+ // Also notify that the loro container atoms have been updated
275
+ const disposeOnSnapshot = onSnapshot(boundObject, () => {
276
+ if (pendingChanges.length === 0) {
277
+ return
278
+ }
279
+
280
+ const changesToApply = pendingChanges
281
+ pendingChanges = []
282
+
283
+ // Skip if we're currently applying Loro changes to MobX
284
+ if (bindingContext.isApplyingLoroChangesToMobxKeystone) {
285
+ return
286
+ }
287
+
288
+ loroDoc.setNextCommitOrigin(loroOrigin)
289
+
290
+ // Apply all changes directly to Loro
291
+ for (const change of changesToApply) {
292
+ applyMobxChangeToLoroObject(change, loroObject)
293
+ }
294
+
295
+ loroDoc.commit()
296
+
297
+ // Update snapshot tracking: the Loro container is now in sync with the current MobX snapshot
298
+ if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
299
+ setLoroContainerSnapshot(loroObject, getSnapshot(boundObject))
300
+ }
301
+
302
+ // Notify MobX that the Loro container has been updated
303
+ getOrCreateLoroCollectionAtom(loroObject).reportChanged()
304
+ })
305
+
306
+ // Sync the model snapshot to the CRDT if any init changes occurred.
307
+ // Init changes include: defaults being applied, onInit hooks mutating the model.
308
+ // This is an optimization: if no init changes occurred, we skip the sync entirely.
309
+ const finalSnapshot = getSnapshot(boundObject)
310
+
311
+ if (hasInitChanges) {
312
+ loroDoc.setNextCommitOrigin(loroOrigin)
313
+
314
+ if (loroObject instanceof LoroMap) {
315
+ applyJsonObjectToLoroMap(loroObject, finalSnapshot as PlainObject, { mode: "merge" })
316
+ } else if (loroObject instanceof LoroMovableList) {
317
+ applyJsonArrayToLoroMovableList(loroObject, finalSnapshot as PlainArray, { mode: "merge" })
318
+ } else if (loroObject instanceof LoroText) {
319
+ // For LoroText, we need to handle LoroTextModel snapshot
320
+ const snapshot = finalSnapshot as Record<string, unknown>
321
+ if (snapshot.$modelType === loroTextModelId) {
322
+ // Clear existing content and apply deltas
323
+ if (loroObject.length > 0) {
324
+ loroObject.delete(0, loroObject.length)
325
+ }
326
+ const deltas = extractTextDeltaFromSnapshot(snapshot.deltaList)
327
+ if (deltas.length > 0) {
328
+ applyDeltaToLoroText(loroObject, deltas)
329
+ }
330
+ }
331
+ }
332
+
333
+ loroDoc.commit()
334
+ }
335
+
336
+ // Always update snapshot tracking after binding initialization
337
+ // This ensures the merge optimization can skip unchanged subtrees in future reconciliations
338
+ if (loroObject instanceof LoroMap || loroObject instanceof LoroMovableList) {
339
+ setLoroContainerSnapshot(loroObject, finalSnapshot)
340
+ }
341
+
342
+ const dispose = () => {
343
+ loroUnsubscribe()
344
+ disposeOnDeepChange()
345
+ disposeOnSnapshot()
346
+ }
347
+
348
+ return {
349
+ boundObject,
350
+ dispose,
351
+ loroOrigin,
352
+ }
353
+ }
@@ -0,0 +1,315 @@
1
+ import type { Delta } from "loro-crdt"
2
+ import { LoroMap, LoroMovableList, LoroText } from "loro-crdt"
3
+ import { frozenKey, isFrozenSnapshot } from "mobx-keystone"
4
+ import type { PlainArray, PlainObject, PlainPrimitive, PlainValue } from "../plainTypes"
5
+ import {
6
+ type BindableLoroContainer,
7
+ isBindableLoroContainer,
8
+ } from "../utils/isBindableLoroContainer"
9
+ import { isLoroTextModelSnapshot } from "./LoroTextModel"
10
+ import { isLoroContainerUpToDate, setLoroContainerSnapshot } from "./loroSnapshotTracking"
11
+
12
+ type LoroValue = BindableLoroContainer | PlainValue
13
+
14
+ /**
15
+ * Options for applying JSON data to Loro data structures.
16
+ */
17
+ export interface ApplyJsonToLoroOptions {
18
+ /**
19
+ * The mode to use when applying JSON data to Loro data structures.
20
+ * - `add`: Creates new Loro containers for objects/arrays (default, backwards compatible)
21
+ * - `merge`: Recursively merges values, preserving existing container references where possible
22
+ */
23
+ mode?: "add" | "merge"
24
+ }
25
+
26
+ function isPlainPrimitive(v: PlainValue): v is PlainPrimitive {
27
+ const t = typeof v
28
+ return t === "string" || t === "number" || t === "boolean" || v === null || v === undefined
29
+ }
30
+
31
+ function isPlainArray(v: PlainValue): v is PlainArray {
32
+ return Array.isArray(v)
33
+ }
34
+
35
+ function isPlainObject(v: PlainValue): v is PlainObject {
36
+ return typeof v === "object" && v !== null && !Array.isArray(v)
37
+ }
38
+
39
+ /**
40
+ * Extracts delta array from a LoroTextModel snapshot's delta field.
41
+ * The delta field is a frozen Delta<string>[] (array of delta operations).
42
+ */
43
+ export function extractTextDeltaFromSnapshot(delta: unknown): Delta<string>[] {
44
+ // The delta field is frozen, so we need to extract it
45
+ if (isFrozenSnapshot<Delta<string>[]>(delta)) {
46
+ const data = delta.data
47
+ if (Array.isArray(data)) {
48
+ return data
49
+ }
50
+ }
51
+
52
+ // Handle plain delta array (not wrapped in frozen)
53
+ if (Array.isArray(delta)) {
54
+ return delta as Delta<string>[]
55
+ }
56
+
57
+ return []
58
+ }
59
+
60
+ /**
61
+ * Applies delta operations to a LoroText using insert/mark APIs.
62
+ * This works on both attached and detached containers.
63
+ *
64
+ * Strategy: Insert all text first, then apply marks. This avoids mark inheritance
65
+ * issues when inserting at the boundary of a marked region.
66
+ */
67
+ export function applyDeltaToLoroText(text: LoroText, deltas: Delta<string>[]): void {
68
+ // Phase 1: Insert all text content
69
+ let position = 0
70
+ const markOperations: Array<{
71
+ start: number
72
+ end: number
73
+ attributes: Record<string, unknown>
74
+ }> = []
75
+
76
+ for (const delta of deltas) {
77
+ if (delta.insert !== undefined) {
78
+ const content = delta.insert
79
+ text.insert(position, content)
80
+
81
+ // Collect mark operations to apply later
82
+ if (delta.attributes && Object.keys(delta.attributes).length > 0) {
83
+ markOperations.push({
84
+ start: position,
85
+ end: position + content.length,
86
+ attributes: delta.attributes,
87
+ })
88
+ }
89
+
90
+ position += content.length
91
+ } else if (delta.retain) {
92
+ position += delta.retain
93
+ } else if (delta.delete) {
94
+ text.delete(position, delta.delete)
95
+ }
96
+ }
97
+
98
+ // Phase 2: Apply all marks after text is inserted
99
+ for (const op of markOperations) {
100
+ for (const [key, value] of Object.entries(op.attributes)) {
101
+ text.mark({ start: op.start, end: op.end }, key, value)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Converts a plain value to a Loro data structure.
108
+ * Objects are converted to LoroMaps, arrays to LoroMovableLists, primitives are untouched.
109
+ * Frozen values are a special case and they are kept as immutable plain values.
110
+ */
111
+ export function convertJsonToLoroData(v: PlainValue): LoroValue {
112
+ if (isPlainPrimitive(v)) {
113
+ return v
114
+ }
115
+
116
+ if (isPlainArray(v)) {
117
+ const list = new LoroMovableList()
118
+ applyJsonArrayToLoroMovableList(list, v)
119
+ return list
120
+ }
121
+
122
+ if (isPlainObject(v)) {
123
+ if (v[frozenKey] === true) {
124
+ // frozen value with explicit $frozen marker (shouldn't reach here after above check)
125
+ return v
126
+ }
127
+
128
+ if (isLoroTextModelSnapshot(v)) {
129
+ const text = new LoroText()
130
+ // Extract delta from the snapshot and apply using insert/mark APIs
131
+ // (applyDelta doesn't work on detached containers, but insert/mark do)
132
+ const deltas = extractTextDeltaFromSnapshot(v.deltaList)
133
+ if (deltas.length > 0) {
134
+ applyDeltaToLoroText(text, deltas)
135
+ }
136
+ return text
137
+ }
138
+
139
+ const map = new LoroMap()
140
+ applyJsonObjectToLoroMap(map, v)
141
+ return map
142
+ }
143
+
144
+ throw new Error(`unsupported value type: ${v}`)
145
+ }
146
+
147
+ /**
148
+ * Applies a JSON array to a LoroMovableList, using convertJsonToLoroData to convert the values.
149
+ *
150
+ * @param dest The destination LoroMovableList.
151
+ * @param source The source JSON array.
152
+ * @param options Options for applying the JSON data.
153
+ */
154
+ export const applyJsonArrayToLoroMovableList = (
155
+ dest: LoroMovableList,
156
+ source: PlainArray,
157
+ options: ApplyJsonToLoroOptions = {}
158
+ ) => {
159
+ const { mode = "add" } = options
160
+
161
+ if (mode === "add") {
162
+ // Add mode: just push all items to the end
163
+ for (const item of source) {
164
+ const converted = convertJsonToLoroData(item)
165
+ if (isBindableLoroContainer(converted)) {
166
+ dest.pushContainer(converted)
167
+ } else {
168
+ dest.push(converted)
169
+ }
170
+ }
171
+ return
172
+ }
173
+
174
+ // Merge mode: recursively merge values, preserving existing container references
175
+ // In merge mode, check if the container is already up-to-date with this snapshot
176
+ if (isLoroContainerUpToDate(dest, source)) {
177
+ return
178
+ }
179
+
180
+ // Remove extra items from the end
181
+ const destLen = dest.length
182
+ const srcLen = source.length
183
+ if (destLen > srcLen) {
184
+ dest.delete(srcLen, destLen - srcLen)
185
+ }
186
+
187
+ // Update existing items
188
+ const minLen = Math.min(destLen, srcLen)
189
+ for (let i = 0; i < minLen; i++) {
190
+ const srcItem = source[i]
191
+ const destItem = dest.get(i)
192
+
193
+ // If both are objects, merge recursively
194
+ if (isPlainObject(srcItem) && destItem instanceof LoroMap) {
195
+ applyJsonObjectToLoroMap(destItem, srcItem, options)
196
+ continue
197
+ }
198
+
199
+ // If both are arrays, merge recursively
200
+ if (isPlainArray(srcItem) && destItem instanceof LoroMovableList) {
201
+ applyJsonArrayToLoroMovableList(destItem, srcItem, options)
202
+ continue
203
+ }
204
+
205
+ // Skip if primitive value is unchanged (optimization)
206
+ if (isPlainPrimitive(srcItem) && destItem === srcItem) {
207
+ continue
208
+ }
209
+
210
+ // Otherwise, replace the item
211
+ dest.delete(i, 1)
212
+ const converted = convertJsonToLoroData(srcItem)
213
+ if (isBindableLoroContainer(converted)) {
214
+ dest.insertContainer(i, converted)
215
+ } else {
216
+ dest.insert(i, converted)
217
+ }
218
+ }
219
+
220
+ // Add new items at the end
221
+ for (let i = destLen; i < srcLen; i++) {
222
+ const converted = convertJsonToLoroData(source[i])
223
+ if (isBindableLoroContainer(converted)) {
224
+ dest.pushContainer(converted)
225
+ } else {
226
+ dest.push(converted)
227
+ }
228
+ }
229
+
230
+ // Update snapshot tracking after successful merge
231
+ setLoroContainerSnapshot(dest, source)
232
+ }
233
+
234
+ /**
235
+ * Applies a JSON object to a LoroMap, using convertJsonToLoroData to convert the values.
236
+ *
237
+ * @param dest The destination LoroMap.
238
+ * @param source The source JSON object.
239
+ * @param options Options for applying the JSON data.
240
+ */
241
+ export const applyJsonObjectToLoroMap = (
242
+ dest: LoroMap,
243
+ source: PlainObject,
244
+ options: ApplyJsonToLoroOptions = {}
245
+ ) => {
246
+ const { mode = "add" } = options
247
+
248
+ if (mode === "add") {
249
+ // Add mode: just set all values
250
+ for (const k of Object.keys(source)) {
251
+ const v = source[k]
252
+ if (v !== undefined) {
253
+ const converted = convertJsonToLoroData(v)
254
+ if (isBindableLoroContainer(converted)) {
255
+ dest.setContainer(k, converted)
256
+ } else {
257
+ dest.set(k, converted)
258
+ }
259
+ }
260
+ }
261
+ return
262
+ }
263
+
264
+ // Merge mode: recursively merge values, preserving existing container references
265
+ // In merge mode, check if the container is already up-to-date with this snapshot
266
+ if (isLoroContainerUpToDate(dest, source)) {
267
+ return
268
+ }
269
+
270
+ // Delete keys that are not present in source (or have undefined value)
271
+ const sourceKeysWithValues = new Set(Object.keys(source).filter((k) => source[k] !== undefined))
272
+ for (const key of dest.keys()) {
273
+ if (!sourceKeysWithValues.has(key)) {
274
+ dest.delete(key)
275
+ }
276
+ }
277
+
278
+ for (const k of Object.keys(source)) {
279
+ const v = source[k]
280
+ // Skip undefined values - Loro maps cannot store undefined
281
+ if (v === undefined) {
282
+ continue
283
+ }
284
+
285
+ const existing = dest.get(k)
286
+
287
+ // If source is an object and dest has a LoroMap, merge recursively
288
+ if (isPlainObject(v) && existing instanceof LoroMap) {
289
+ applyJsonObjectToLoroMap(existing, v, options)
290
+ continue
291
+ }
292
+
293
+ // If source is an array and dest has a LoroMovableList, merge recursively
294
+ if (isPlainArray(v) && existing instanceof LoroMovableList) {
295
+ applyJsonArrayToLoroMovableList(existing, v, options)
296
+ continue
297
+ }
298
+
299
+ // Skip if primitive value is unchanged (optimization)
300
+ if (isPlainPrimitive(v) && existing === v) {
301
+ continue
302
+ }
303
+
304
+ // Otherwise, convert and set the value (this creates new containers if needed)
305
+ const converted = convertJsonToLoroData(v)
306
+ if (isBindableLoroContainer(converted)) {
307
+ dest.setContainer(k, converted)
308
+ } else {
309
+ dest.set(k, converted)
310
+ }
311
+ }
312
+
313
+ // Update snapshot tracking after successful merge
314
+ setLoroContainerSnapshot(dest, source)
315
+ }