mobx-keystone-yjs 1.5.4 → 1.6.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 (31) hide show
  1. package/CHANGELOG.md +57 -45
  2. package/dist/mobx-keystone-yjs.esm.js +475 -299
  3. package/dist/mobx-keystone-yjs.esm.mjs +475 -299
  4. package/dist/mobx-keystone-yjs.umd.js +475 -299
  5. package/dist/types/binding/YjsTextModel.d.ts +5 -4
  6. package/dist/types/binding/applyMobxChangeToYjsObject.d.ts +3 -0
  7. package/dist/types/binding/applyYjsEventToMobx.d.ts +8 -0
  8. package/dist/types/binding/bindYjsToMobxKeystone.d.ts +1 -1
  9. package/dist/types/binding/convertJsonToYjsData.d.ts +23 -4
  10. package/dist/types/binding/convertYjsDataToJson.d.ts +1 -1
  11. package/dist/types/binding/resolveYjsPath.d.ts +14 -1
  12. package/dist/types/binding/yjsBindingContext.d.ts +2 -2
  13. package/dist/types/binding/yjsSnapshotTracking.d.ts +24 -0
  14. package/dist/types/index.d.ts +7 -6
  15. package/dist/types/utils/isYjsValueDeleted.d.ts +7 -0
  16. package/package.json +90 -78
  17. package/src/binding/YjsTextModel.ts +280 -247
  18. package/src/binding/applyMobxChangeToYjsObject.ts +77 -0
  19. package/src/binding/applyYjsEventToMobx.ts +173 -0
  20. package/src/binding/bindYjsToMobxKeystone.ts +300 -192
  21. package/src/binding/convertJsonToYjsData.ts +218 -76
  22. package/src/binding/convertYjsDataToJson.ts +1 -1
  23. package/src/binding/resolveYjsPath.ts +51 -27
  24. package/src/binding/yjsSnapshotTracking.ts +40 -0
  25. package/src/index.ts +11 -10
  26. package/src/utils/getOrCreateYjsCollectionAtom.ts +27 -27
  27. package/src/utils/isYjsValueDeleted.ts +14 -0
  28. package/dist/types/binding/applyMobxKeystonePatchToYjsObject.d.ts +0 -2
  29. package/dist/types/binding/convertYjsEventToPatches.d.ts +0 -3
  30. package/src/binding/applyMobxKeystonePatchToYjsObject.ts +0 -98
  31. package/src/binding/convertYjsEventToPatches.ts +0 -92
@@ -0,0 +1,173 @@
1
+ import { remove } from "mobx"
2
+ import {
3
+ Frozen,
4
+ fromSnapshot,
5
+ frozen,
6
+ getSnapshot,
7
+ getSnapshotModelId,
8
+ isFrozenSnapshot,
9
+ isModel,
10
+ Path,
11
+ resolvePath,
12
+ runUnprotected,
13
+ } from "mobx-keystone"
14
+ import * as Y from "yjs"
15
+ import { failure } from "../utils/error"
16
+ import { convertYjsDataToJson } from "./convertYjsDataToJson"
17
+
18
+ // Represents the map of potential objects to reconcile (ID -> Object)
19
+ export type ReconciliationMap = Map<string, object>
20
+
21
+ /**
22
+ * Applies a Y.js event directly to the MobX model tree using proper mutations
23
+ * (splice for arrays, property assignment for objects).
24
+ * This is more efficient than converting to patches first.
25
+ */
26
+ export function applyYjsEventToMobx(
27
+ event: Y.YEvent<any>,
28
+ boundObject: object,
29
+ reconciliationMap: ReconciliationMap
30
+ ): void {
31
+ const path = event.path as Path
32
+ const { value: target } = resolvePath(boundObject, path)
33
+
34
+ if (!target) {
35
+ throw failure(`cannot resolve path ${JSON.stringify(path)}`)
36
+ }
37
+
38
+ // Wrap in runUnprotected since we're modifying the tree from outside a model action
39
+ runUnprotected(() => {
40
+ if (event instanceof Y.YMapEvent) {
41
+ applyYMapEventToMobx(event, target, reconciliationMap)
42
+ } else if (event instanceof Y.YArrayEvent) {
43
+ applyYArrayEventToMobx(event, target, reconciliationMap)
44
+ } else if (event instanceof Y.YTextEvent) {
45
+ applyYTextEventToMobx(event, target)
46
+ }
47
+ })
48
+ }
49
+
50
+ function processDeletedValue(val: unknown, reconciliationMap: ReconciliationMap) {
51
+ if (val && typeof val === "object" && isModel(val)) {
52
+ const sn = getSnapshot(val)
53
+ const id = getSnapshotModelId(sn)
54
+ if (id) {
55
+ reconciliationMap.set(id, val)
56
+ }
57
+ }
58
+ }
59
+
60
+ function reviveValue(jsonValue: any, reconciliationMap: ReconciliationMap): any {
61
+ // Handle primitives
62
+ if (jsonValue === null || typeof jsonValue !== "object") {
63
+ return jsonValue
64
+ }
65
+
66
+ // Handle frozen
67
+ if (isFrozenSnapshot(jsonValue)) {
68
+ return frozen(jsonValue.data)
69
+ }
70
+
71
+ // If we have a reconciliation map and the value looks like a model with an ID, check if we have it
72
+ if (reconciliationMap && jsonValue && typeof jsonValue === "object") {
73
+ const modelId = getSnapshotModelId(jsonValue)
74
+ if (modelId) {
75
+ const existing = reconciliationMap.get(modelId)
76
+ if (existing) {
77
+ reconciliationMap.delete(modelId)
78
+ return existing
79
+ }
80
+ }
81
+ }
82
+
83
+ return fromSnapshot(jsonValue)
84
+ }
85
+
86
+ function applyYMapEventToMobx(
87
+ event: Y.YMapEvent<any>,
88
+ target: Record<string, any>,
89
+ reconciliationMap: ReconciliationMap
90
+ ): void {
91
+ const source = event.target
92
+
93
+ event.changes.keys.forEach((change, key) => {
94
+ switch (change.action) {
95
+ case "add":
96
+ case "update": {
97
+ const yjsValue = source.get(key)
98
+ const jsonValue = convertYjsDataToJson(yjsValue)
99
+
100
+ // If updating, the old value is overwritten (deleted conceptually)
101
+ if (change.action === "update") {
102
+ processDeletedValue(target[key], reconciliationMap)
103
+ }
104
+
105
+ target[key] = reviveValue(jsonValue, reconciliationMap)
106
+ break
107
+ }
108
+
109
+ case "delete": {
110
+ processDeletedValue(target[key], reconciliationMap)
111
+ // Use MobX's remove to properly delete the key from the observable object
112
+ // This triggers the "remove" interceptor in mobx-keystone's tweaker
113
+ if (isModel(target)) {
114
+ remove(target.$, key)
115
+ } else {
116
+ remove(target, key)
117
+ }
118
+ break
119
+ }
120
+
121
+ default:
122
+ throw failure(`unsupported Yjs map event action: ${change.action}`)
123
+ }
124
+ })
125
+ }
126
+
127
+ function applyYArrayEventToMobx(
128
+ event: Y.YArrayEvent<any>,
129
+ target: any[],
130
+ reconciliationMap: ReconciliationMap
131
+ ): void {
132
+ // Process delta operations in order
133
+ let currentIndex = 0
134
+
135
+ for (const change of event.changes.delta) {
136
+ if (change.retain) {
137
+ currentIndex += change.retain
138
+ }
139
+
140
+ if (change.delete) {
141
+ // Capture deleted items for reconciliation
142
+ const deletedItems = target.slice(currentIndex, currentIndex + change.delete)
143
+ deletedItems.forEach((item) => {
144
+ processDeletedValue(item, reconciliationMap)
145
+ })
146
+
147
+ // Delete items at current position
148
+ target.splice(currentIndex, change.delete)
149
+ }
150
+
151
+ if (change.insert) {
152
+ // Insert items at current position
153
+ const insertedItems = Array.isArray(change.insert) ? change.insert : [change.insert]
154
+ const values = insertedItems.map((yjsValue) => {
155
+ const jsonValue = convertYjsDataToJson(yjsValue)
156
+ return reviveValue(jsonValue, reconciliationMap)
157
+ })
158
+
159
+ target.splice(currentIndex, 0, ...values)
160
+ currentIndex += values.length
161
+ }
162
+ }
163
+ }
164
+
165
+ function applyYTextEventToMobx(
166
+ event: Y.YTextEvent,
167
+ target: { deltaList?: Frozen<unknown[]>[] }
168
+ ): void {
169
+ // YjsTextModel handles text events by appending delta to deltaList
170
+ if (target?.deltaList) {
171
+ target.deltaList.push(frozen(event.delta))
172
+ }
173
+ }
@@ -1,192 +1,300 @@
1
- import { action } from "mobx"
2
- import {
3
- AnyDataModel,
4
- AnyModel,
5
- AnyStandardType,
6
- ModelClass,
7
- Patch,
8
- SnapshotInOf,
9
- TypeToData,
10
- applyPatches,
11
- fromSnapshot,
12
- getParentToChildPath,
13
- onGlobalPatches,
14
- onPatches,
15
- onSnapshot,
16
- } from "mobx-keystone"
17
- import * as Y from "yjs"
18
- import { getYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
19
- import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
20
- import { convertYjsDataToJson } from "./convertYjsDataToJson"
21
- import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
22
- import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
23
-
24
- /**
25
- * Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
26
- */
27
- export function bindYjsToMobxKeystone<
28
- TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
29
- >({
30
- yjsDoc,
31
- yjsObject,
32
- mobxKeystoneType,
33
- }: {
34
- /**
35
- * The Y.js document.
36
- */
37
- yjsDoc: Y.Doc
38
- /**
39
- * The bound Y.js data structure.
40
- */
41
- yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
42
- /**
43
- * The mobx-keystone model type.
44
- */
45
- mobxKeystoneType: TType
46
- }): {
47
- /**
48
- * The bound mobx-keystone instance.
49
- */
50
- boundObject: TypeToData<TType>
51
- /**
52
- * Disposes the binding.
53
- */
54
- dispose: () => void
55
- /**
56
- * The Y.js origin symbol used for binding transactions.
57
- */
58
- yjsOrigin: symbol
59
- } {
60
- const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
61
-
62
- let applyingYjsChangesToMobxKeystone = 0
63
-
64
- const bindingContext: YjsBindingContext = {
65
- yjsDoc,
66
- yjsObject,
67
- mobxKeystoneType,
68
- yjsOrigin,
69
- boundObject: undefined, // not yet created
70
-
71
- get isApplyingYjsChangesToMobxKeystone() {
72
- return applyingYjsChangesToMobxKeystone > 0
73
- },
74
- }
75
-
76
- const yjsJson = convertYjsDataToJson(yjsObject)
77
-
78
- const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
79
-
80
- const createBoundObject = () => {
81
- const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
82
- initializationGlobalPatches.push({ target, patches })
83
- })
84
-
85
- try {
86
- const boundObject = yjsBindingContext.apply(
87
- () => fromSnapshot(mobxKeystoneType, yjsJson as unknown as SnapshotInOf<TypeToData<TType>>),
88
- bindingContext
89
- )
90
- yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
91
- return boundObject
92
- } finally {
93
- disposeOnGlobalPatches()
94
- }
95
- }
96
-
97
- const boundObject = createBoundObject()
98
-
99
- // bind any changes from yjs to mobx-keystone
100
- const observeDeepCb = action((events: Y.YEvent<any>[]) => {
101
- const patches: Patch[] = []
102
- events.forEach((event) => {
103
- if (event.transaction.origin !== yjsOrigin) {
104
- patches.push(...convertYjsEventToPatches(event))
105
- }
106
-
107
- if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
108
- getYjsCollectionAtom(event.target)?.reportChanged()
109
- }
110
- })
111
-
112
- if (patches.length > 0) {
113
- applyingYjsChangesToMobxKeystone++
114
- try {
115
- applyPatches(boundObject, patches)
116
- } finally {
117
- applyingYjsChangesToMobxKeystone--
118
- }
119
- }
120
- })
121
-
122
- yjsObject.observeDeep(observeDeepCb)
123
-
124
- // bind any changes from mobx-keystone to yjs
125
- let pendingArrayOfArrayOfPatches: Patch[][] = []
126
- const disposeOnPatches = onPatches(boundObject, (patches) => {
127
- if (applyingYjsChangesToMobxKeystone > 0) {
128
- return
129
- }
130
-
131
- pendingArrayOfArrayOfPatches.push(patches)
132
- })
133
-
134
- // this is only used so we can transact all patches to the snapshot boundary
135
- const disposeOnSnapshot = onSnapshot(boundObject, () => {
136
- if (pendingArrayOfArrayOfPatches.length === 0) {
137
- return
138
- }
139
-
140
- const arrayOfArrayOfPatches = pendingArrayOfArrayOfPatches
141
- pendingArrayOfArrayOfPatches = []
142
-
143
- yjsDoc.transact(() => {
144
- arrayOfArrayOfPatches.forEach((arrayOfPatches) => {
145
- arrayOfPatches.forEach((patch) => {
146
- applyMobxKeystonePatchToYjsObject(patch, yjsObject)
147
- })
148
- })
149
- }, yjsOrigin)
150
- })
151
-
152
- // sync initial patches, that might include setting defaults, IDs, etc
153
- yjsDoc.transact(() => {
154
- // we need to skip initializations until we hit the initialization of the bound object
155
- // this is because default objects might be created and initialized before the main object
156
- // but we just need to catch when those are actually assigned to the bound object
157
- let boundObjectFound = false
158
-
159
- initializationGlobalPatches.forEach(({ target, patches }) => {
160
- if (!boundObjectFound) {
161
- if (target !== boundObject) {
162
- return // skip
163
- }
164
- boundObjectFound = true
165
- }
166
-
167
- const parentToChildPath = getParentToChildPath(boundObject, target)
168
- // this is undefined only if target is not a child of boundModel
169
- if (parentToChildPath !== undefined) {
170
- patches.forEach((patch) => {
171
- applyMobxKeystonePatchToYjsObject(
172
- {
173
- ...patch,
174
- path: [...parentToChildPath, ...patch.path],
175
- },
176
- yjsObject
177
- )
178
- })
179
- }
180
- })
181
- }, yjsOrigin)
182
-
183
- return {
184
- boundObject,
185
- dispose: () => {
186
- disposeOnPatches()
187
- disposeOnSnapshot()
188
- yjsObject.unobserveDeep(observeDeepCb)
189
- },
190
- yjsOrigin,
191
- }
192
- }
1
+ import { action } from "mobx"
2
+ import {
3
+ AnyDataModel,
4
+ AnyModel,
5
+ AnyStandardType,
6
+ DeepChange,
7
+ DeepChangeType,
8
+ fromSnapshot,
9
+ getParentToChildPath,
10
+ getSnapshot,
11
+ isTreeNode,
12
+ ModelClass,
13
+ onDeepChange,
14
+ onGlobalDeepChange,
15
+ onSnapshot,
16
+ SnapshotInOf,
17
+ TypeToData,
18
+ } from "mobx-keystone"
19
+ import * as Y from "yjs"
20
+ import type { PlainArray, PlainObject } from "../plainTypes"
21
+ import { failure } from "../utils/error"
22
+ import { getYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
23
+ import { isYjsValueDeleted } from "../utils/isYjsValueDeleted"
24
+ import { applyMobxChangeToYjsObject } from "./applyMobxChangeToYjsObject"
25
+ import { applyYjsEventToMobx, ReconciliationMap } from "./applyYjsEventToMobx"
26
+ import { applyJsonArrayToYArray, applyJsonObjectToYMap } from "./convertJsonToYjsData"
27
+ import { convertYjsDataToJson } from "./convertYjsDataToJson"
28
+ import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
29
+ import { setYjsContainerSnapshot } from "./yjsSnapshotTracking"
30
+
31
+ /**
32
+ * Captures snapshots of tree nodes in a DeepChange.
33
+ * This ensures values are captured at change time, not at apply time,
34
+ * preventing issues when values are mutated after being added to a collection.
35
+ */
36
+ function captureChangeSnapshots(change: DeepChange): DeepChange {
37
+ if (change.type === DeepChangeType.ArraySplice && change.addedValues.length > 0) {
38
+ const snapshots = change.addedValues.map((v) => (isTreeNode(v) ? getSnapshot(v) : v))
39
+ return { ...change, addedValues: snapshots }
40
+ } else if (change.type === DeepChangeType.ArrayUpdate) {
41
+ const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
42
+ return { ...change, newValue: snapshot }
43
+ } else if (
44
+ change.type === DeepChangeType.ObjectAdd ||
45
+ change.type === DeepChangeType.ObjectUpdate
46
+ ) {
47
+ const snapshot = isTreeNode(change.newValue) ? getSnapshot(change.newValue) : change.newValue
48
+ return { ...change, newValue: snapshot }
49
+ }
50
+ return change
51
+ }
52
+
53
+ /**
54
+ * Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
55
+ */
56
+ export function bindYjsToMobxKeystone<
57
+ TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
58
+ >({
59
+ yjsDoc,
60
+ yjsObject,
61
+ mobxKeystoneType,
62
+ }: {
63
+ /**
64
+ * The Y.js document.
65
+ */
66
+ yjsDoc: Y.Doc
67
+ /**
68
+ * The bound Y.js data structure.
69
+ */
70
+ yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
71
+ /**
72
+ * The mobx-keystone model type.
73
+ */
74
+ mobxKeystoneType: TType
75
+ }): {
76
+ /**
77
+ * The bound mobx-keystone instance.
78
+ */
79
+ boundObject: TypeToData<TType>
80
+ /**
81
+ * Disposes the binding.
82
+ */
83
+ dispose: () => void
84
+ /**
85
+ * The Y.js origin symbol used for binding transactions.
86
+ */
87
+ yjsOrigin: symbol
88
+ } {
89
+ const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
90
+
91
+ let applyingYjsChangesToMobxKeystone = 0
92
+
93
+ const bindingContext: YjsBindingContext = {
94
+ yjsDoc,
95
+ yjsObject,
96
+ mobxKeystoneType,
97
+ yjsOrigin,
98
+ boundObject: undefined, // not yet created
99
+
100
+ get isApplyingYjsChangesToMobxKeystone() {
101
+ return applyingYjsChangesToMobxKeystone > 0
102
+ },
103
+ }
104
+
105
+ if (isYjsValueDeleted(yjsObject)) {
106
+ throw failure("cannot apply patch to deleted Yjs value")
107
+ }
108
+
109
+ const yjsJson = convertYjsDataToJson(yjsObject)
110
+
111
+ let boundObject: TypeToData<TType>
112
+
113
+ // Track if any init changes occur during fromSnapshot
114
+ // (e.g., defaults being applied, onInit hooks mutating the model)
115
+ let hasInitChanges = false
116
+
117
+ const createBoundObject = () => {
118
+ // Set up a temporary global listener to detect if any init changes occur during fromSnapshot
119
+ const disposeGlobalListener = onGlobalDeepChange((_target, change) => {
120
+ if (change.isInit) {
121
+ hasInitChanges = true
122
+ }
123
+ })
124
+
125
+ try {
126
+ const result = yjsBindingContext.apply(
127
+ () => fromSnapshot(mobxKeystoneType, yjsJson as unknown as SnapshotInOf<TypeToData<TType>>),
128
+ bindingContext
129
+ )
130
+ yjsBindingContext.set(result, { ...bindingContext, boundObject: result })
131
+ return result
132
+ } finally {
133
+ disposeGlobalListener()
134
+ }
135
+ }
136
+
137
+ boundObject = createBoundObject()
138
+
139
+ // bind any changes from yjs to mobx-keystone
140
+ const observeDeepCb = action((events: Y.YEvent<any>[]) => {
141
+ const eventsToApply: Y.YEvent<any>[] = []
142
+
143
+ events.forEach((event) => {
144
+ if (event.transaction.origin !== yjsOrigin) {
145
+ eventsToApply.push(event)
146
+ }
147
+
148
+ if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
149
+ getYjsCollectionAtom(event.target)?.reportChanged()
150
+ }
151
+ })
152
+
153
+ if (eventsToApply.length > 0) {
154
+ applyingYjsChangesToMobxKeystone++
155
+ try {
156
+ const reconciliationMap: ReconciliationMap = new Map()
157
+
158
+ // Collect init changes that occur during event application
159
+ // (e.g., fromSnapshot calls that trigger onInit hooks)
160
+ // We store both target and change so we can compute the correct path later
161
+ // Snapshots are captured immediately to preserve values at init time
162
+ const initChanges: { target: object; change: DeepChange }[] = []
163
+ const disposeGlobalListener = onGlobalDeepChange((target, change) => {
164
+ if (change.isInit) {
165
+ initChanges.push({ target, change: captureChangeSnapshots(change) })
166
+ }
167
+ })
168
+
169
+ try {
170
+ eventsToApply.forEach((event) => {
171
+ applyYjsEventToMobx(event, boundObject, reconciliationMap)
172
+ })
173
+ } finally {
174
+ disposeGlobalListener()
175
+ }
176
+
177
+ // Sync back any init-time mutations from fromSnapshot calls
178
+ // (e.g., onInit hooks that modify the model)
179
+ // This is needed because init changes during Yjs event handling are not
180
+ // captured by the main onDeepChange (it skips changes when applyingYjsChangesToMobxKeystone > 0)
181
+ if (initChanges.length > 0 && !isYjsValueDeleted(yjsObject)) {
182
+ yjsDoc.transact(() => {
183
+ for (const { target, change } of initChanges) {
184
+ // Compute the path from boundObject to the target
185
+ const pathToTarget = getParentToChildPath(boundObject, target)
186
+ if (pathToTarget !== undefined) {
187
+ // Create a new change with the correct path from the root
188
+ const changeWithCorrectPath: DeepChange = {
189
+ ...change,
190
+ path: [...pathToTarget, ...change.path],
191
+ }
192
+ applyMobxChangeToYjsObject(changeWithCorrectPath, yjsObject)
193
+ }
194
+ }
195
+ }, yjsOrigin)
196
+ }
197
+
198
+ // Update snapshot tracking: the Y.js container is now in sync with the current MobX snapshot
199
+ // This enables the merge optimization to skip unchanged subtrees during reconciliation
200
+ if (yjsObject instanceof Y.Map || yjsObject instanceof Y.Array) {
201
+ setYjsContainerSnapshot(yjsObject, getSnapshot(boundObject))
202
+ }
203
+ } finally {
204
+ applyingYjsChangesToMobxKeystone--
205
+ }
206
+ }
207
+ })
208
+
209
+ yjsObject.observeDeep(observeDeepCb)
210
+
211
+ // bind any changes from mobx-keystone to yjs using deep change observation
212
+ // This provides proper splice detection for array operations
213
+ let pendingChanges: DeepChange[] = []
214
+
215
+ const disposeOnDeepChange = onDeepChange(boundObject, (change) => {
216
+ if (applyingYjsChangesToMobxKeystone > 0) {
217
+ return
218
+ }
219
+
220
+ // Skip init changes - they are handled by the getSnapshot + merge at the end of binding
221
+ if (change.isInit) {
222
+ return
223
+ }
224
+
225
+ // Capture snapshots now before the values can be mutated within the same transaction.
226
+ // This is necessary because changes are collected and applied after the action completes,
227
+ // by which time the original values may have been modified.
228
+ // Example: `obj.items = [a, b]; obj.items.splice(0, 1)` - without early capture,
229
+ // the ObjectUpdate for `items` would get the post-splice array state.
230
+ pendingChanges.push(captureChangeSnapshots(change))
231
+ })
232
+
233
+ // this is only used so we can transact all changes to the snapshot boundary
234
+ const disposeOnSnapshot = onSnapshot(boundObject, (boundObjectSnapshot) => {
235
+ if (pendingChanges.length === 0) {
236
+ return
237
+ }
238
+
239
+ const changesToApply = pendingChanges
240
+ pendingChanges = []
241
+
242
+ // Skip syncing to Yjs if the Yjs object has been deleted/detached
243
+ if (isYjsValueDeleted(yjsObject)) {
244
+ return
245
+ }
246
+
247
+ yjsDoc.transact(() => {
248
+ changesToApply.forEach((change) => {
249
+ applyMobxChangeToYjsObject(change, yjsObject)
250
+ })
251
+ }, yjsOrigin)
252
+
253
+ // Update snapshot tracking: the Y.js container is now in sync with the current MobX snapshot
254
+ if (yjsObject instanceof Y.Map || yjsObject instanceof Y.Array) {
255
+ setYjsContainerSnapshot(yjsObject, boundObjectSnapshot)
256
+ }
257
+ })
258
+
259
+ // Sync the init changes to the CRDT.
260
+ // Init changes include: defaults being applied, onInit hooks mutating the model.
261
+ // We use getSnapshot + merge because the per-change approach has issues with reference mutation
262
+ // (values captured in DeepChange can be mutated before we apply them).
263
+ // The snapshot tracking optimization ensures unchanged subtrees are skipped during merge.
264
+ const finalSnapshot = getSnapshot(boundObject)
265
+
266
+ if (hasInitChanges) {
267
+ yjsDoc.transact(() => {
268
+ if (yjsObject instanceof Y.Map) {
269
+ applyJsonObjectToYMap(yjsObject, finalSnapshot as unknown as PlainObject, {
270
+ mode: "merge",
271
+ })
272
+ } else if (yjsObject instanceof Y.Array) {
273
+ applyJsonArrayToYArray(yjsObject, finalSnapshot as unknown as PlainArray, {
274
+ mode: "merge",
275
+ })
276
+ }
277
+ }, yjsOrigin)
278
+ }
279
+
280
+ // Always update snapshot tracking after binding initialization
281
+ // This ensures the merge optimization can skip unchanged subtrees in future reconciliations
282
+ if (yjsObject instanceof Y.Map || yjsObject instanceof Y.Array) {
283
+ setYjsContainerSnapshot(yjsObject, finalSnapshot)
284
+ }
285
+
286
+ const dispose = () => {
287
+ yjsDoc.off("destroy", dispose)
288
+ disposeOnDeepChange()
289
+ disposeOnSnapshot()
290
+ yjsObject.unobserveDeep(observeDeepCb)
291
+ }
292
+
293
+ yjsDoc.on("destroy", dispose)
294
+
295
+ return {
296
+ boundObject,
297
+ dispose,
298
+ yjsOrigin,
299
+ }
300
+ }