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.
- package/CHANGELOG.md +57 -45
- package/dist/mobx-keystone-yjs.esm.js +475 -299
- package/dist/mobx-keystone-yjs.esm.mjs +475 -299
- package/dist/mobx-keystone-yjs.umd.js +475 -299
- package/dist/types/binding/YjsTextModel.d.ts +5 -4
- package/dist/types/binding/applyMobxChangeToYjsObject.d.ts +3 -0
- package/dist/types/binding/applyYjsEventToMobx.d.ts +8 -0
- package/dist/types/binding/bindYjsToMobxKeystone.d.ts +1 -1
- package/dist/types/binding/convertJsonToYjsData.d.ts +23 -4
- package/dist/types/binding/convertYjsDataToJson.d.ts +1 -1
- package/dist/types/binding/resolveYjsPath.d.ts +14 -1
- package/dist/types/binding/yjsBindingContext.d.ts +2 -2
- package/dist/types/binding/yjsSnapshotTracking.d.ts +24 -0
- package/dist/types/index.d.ts +7 -6
- package/dist/types/utils/isYjsValueDeleted.d.ts +7 -0
- package/package.json +90 -78
- package/src/binding/YjsTextModel.ts +280 -247
- package/src/binding/applyMobxChangeToYjsObject.ts +77 -0
- package/src/binding/applyYjsEventToMobx.ts +173 -0
- package/src/binding/bindYjsToMobxKeystone.ts +300 -192
- package/src/binding/convertJsonToYjsData.ts +218 -76
- package/src/binding/convertYjsDataToJson.ts +1 -1
- package/src/binding/resolveYjsPath.ts +51 -27
- package/src/binding/yjsSnapshotTracking.ts +40 -0
- package/src/index.ts +11 -10
- package/src/utils/getOrCreateYjsCollectionAtom.ts +27 -27
- package/src/utils/isYjsValueDeleted.ts +14 -0
- package/dist/types/binding/applyMobxKeystonePatchToYjsObject.d.ts +0 -2
- package/dist/types/binding/convertYjsEventToPatches.d.ts +0 -3
- package/src/binding/applyMobxKeystonePatchToYjsObject.ts +0 -98
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
onSnapshot,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
}
|