mobx-keystone-yjs 1.5.5 → 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.
@@ -1,202 +1,300 @@
1
- import { action } from "mobx"
2
- import {
3
- AnyDataModel,
4
- AnyModel,
5
- AnyStandardType,
6
- applyPatches,
7
- fromSnapshot,
8
- getParentToChildPath,
9
- ModelClass,
10
- onGlobalPatches,
11
- onPatches,
12
- onSnapshot,
13
- Patch,
14
- SnapshotInOf,
15
- TypeToData,
16
- } from "mobx-keystone"
17
- import * as Y from "yjs"
18
- import { getYjsCollectionAtom } from "../utils/getOrCreateYjsCollectionAtom"
19
- import { isYjsValueDeleted } from "../utils/isYjsValueDeleted"
20
- import { applyMobxKeystonePatchToYjsObject } from "./applyMobxKeystonePatchToYjsObject"
21
- import { convertYjsDataToJson } from "./convertYjsDataToJson"
22
- import { convertYjsEventToPatches } from "./convertYjsEventToPatches"
23
- import { YjsBindingContext, yjsBindingContext } from "./yjsBindingContext"
24
-
25
- /**
26
- * Creates a bidirectional binding between a Y.js data structure and a mobx-keystone model.
27
- */
28
- export function bindYjsToMobxKeystone<
29
- TType extends AnyStandardType | ModelClass<AnyModel> | ModelClass<AnyDataModel>,
30
- >({
31
- yjsDoc,
32
- yjsObject,
33
- mobxKeystoneType,
34
- }: {
35
- /**
36
- * The Y.js document.
37
- */
38
- yjsDoc: Y.Doc
39
- /**
40
- * The bound Y.js data structure.
41
- */
42
- yjsObject: Y.Map<any> | Y.Array<any> | Y.Text
43
- /**
44
- * The mobx-keystone model type.
45
- */
46
- mobxKeystoneType: TType
47
- }): {
48
- /**
49
- * The bound mobx-keystone instance.
50
- */
51
- boundObject: TypeToData<TType>
52
- /**
53
- * Disposes the binding.
54
- */
55
- dispose: () => void
56
- /**
57
- * The Y.js origin symbol used for binding transactions.
58
- */
59
- yjsOrigin: symbol
60
- } {
61
- const yjsOrigin = Symbol("bindYjsToMobxKeystoneTransactionOrigin")
62
-
63
- let applyingYjsChangesToMobxKeystone = 0
64
-
65
- const bindingContext: YjsBindingContext = {
66
- yjsDoc,
67
- yjsObject,
68
- mobxKeystoneType,
69
- yjsOrigin,
70
- boundObject: undefined, // not yet created
71
-
72
- get isApplyingYjsChangesToMobxKeystone() {
73
- return applyingYjsChangesToMobxKeystone > 0
74
- },
75
- }
76
-
77
- const yjsJson = convertYjsDataToJson(yjsObject)
78
-
79
- const initializationGlobalPatches: { target: object; patches: Patch[] }[] = []
80
-
81
- const createBoundObject = () => {
82
- const disposeOnGlobalPatches = onGlobalPatches((target, patches) => {
83
- initializationGlobalPatches.push({ target, patches })
84
- })
85
-
86
- try {
87
- const boundObject = yjsBindingContext.apply(
88
- () => fromSnapshot(mobxKeystoneType, yjsJson as unknown as SnapshotInOf<TypeToData<TType>>),
89
- bindingContext
90
- )
91
- yjsBindingContext.set(boundObject, { ...bindingContext, boundObject })
92
- return boundObject
93
- } finally {
94
- disposeOnGlobalPatches()
95
- }
96
- }
97
-
98
- const boundObject = createBoundObject()
99
-
100
- // bind any changes from yjs to mobx-keystone
101
- const observeDeepCb = action((events: Y.YEvent<any>[]) => {
102
- const patches: Patch[] = []
103
- events.forEach((event) => {
104
- if (event.transaction.origin !== yjsOrigin) {
105
- patches.push(...convertYjsEventToPatches(event))
106
- }
107
-
108
- if (event.target instanceof Y.Map || event.target instanceof Y.Array) {
109
- getYjsCollectionAtom(event.target)?.reportChanged()
110
- }
111
- })
112
-
113
- if (patches.length > 0) {
114
- applyingYjsChangesToMobxKeystone++
115
- try {
116
- applyPatches(boundObject, patches)
117
- } finally {
118
- applyingYjsChangesToMobxKeystone--
119
- }
120
- }
121
- })
122
-
123
- yjsObject.observeDeep(observeDeepCb)
124
-
125
- // bind any changes from mobx-keystone to yjs
126
- let pendingArrayOfArrayOfPatches: Patch[][] = []
127
- const disposeOnPatches = onPatches(boundObject, (patches) => {
128
- if (applyingYjsChangesToMobxKeystone > 0) {
129
- return
130
- }
131
-
132
- pendingArrayOfArrayOfPatches.push(patches)
133
- })
134
-
135
- // this is only used so we can transact all patches to the snapshot boundary
136
- const disposeOnSnapshot = onSnapshot(boundObject, () => {
137
- if (pendingArrayOfArrayOfPatches.length === 0) {
138
- return
139
- }
140
-
141
- const arrayOfArrayOfPatches = pendingArrayOfArrayOfPatches
142
- pendingArrayOfArrayOfPatches = []
143
-
144
- if (isYjsValueDeleted(yjsObject)) {
145
- return
146
- }
147
-
148
- yjsDoc.transact(() => {
149
- arrayOfArrayOfPatches.forEach((arrayOfPatches) => {
150
- arrayOfPatches.forEach((patch) => {
151
- applyMobxKeystonePatchToYjsObject(patch, yjsObject)
152
- })
153
- })
154
- }, yjsOrigin)
155
- })
156
-
157
- // sync initial patches, that might include setting defaults, IDs, etc
158
- yjsDoc.transact(() => {
159
- // we need to skip initializations until we hit the initialization of the bound object
160
- // this is because default objects might be created and initialized before the main object
161
- // but we just need to catch when those are actually assigned to the bound object
162
- let boundObjectFound = false
163
-
164
- initializationGlobalPatches.forEach(({ target, patches }) => {
165
- if (!boundObjectFound) {
166
- if (target !== boundObject) {
167
- return // skip
168
- }
169
- boundObjectFound = true
170
- }
171
-
172
- const parentToChildPath = getParentToChildPath(boundObject, target)
173
- // this is undefined only if target is not a child of boundModel
174
- if (parentToChildPath !== undefined) {
175
- patches.forEach((patch) => {
176
- applyMobxKeystonePatchToYjsObject(
177
- {
178
- ...patch,
179
- path: [...parentToChildPath, ...patch.path],
180
- },
181
- yjsObject
182
- )
183
- })
184
- }
185
- })
186
- }, yjsOrigin)
187
-
188
- const dispose = () => {
189
- yjsDoc.off("destroy", dispose)
190
- disposeOnPatches()
191
- disposeOnSnapshot()
192
- yjsObject.unobserveDeep(observeDeepCb)
193
- }
194
-
195
- yjsDoc.on("destroy", dispose)
196
-
197
- return {
198
- boundObject,
199
- dispose,
200
- yjsOrigin,
201
- }
202
- }
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
+ }