mobx-vue-bridge 1.4.0 → 1.5.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 +52 -0
- package/package.json +1 -1
- package/src/mobxVueBridge.js +157 -222
- package/src/utils/equality.js +25 -9
- package/src/utils/helpers.js +395 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-01-13
|
|
9
|
+
|
|
10
|
+
### 🎯 Major Improvements
|
|
11
|
+
|
|
12
|
+
#### Declarative helper functions for improved readability
|
|
13
|
+
- **Feature**: Extracted bridge logic into self-documenting helper functions in `src/utils/helpers.js`
|
|
14
|
+
- **Structure**: 396 lines of declarative helpers organized by concern:
|
|
15
|
+
- Value reading & initialization
|
|
16
|
+
- Property type categorization
|
|
17
|
+
- Echo loop prevention
|
|
18
|
+
- Read-only detection
|
|
19
|
+
- Setter creation (lazy validated, read-only, write-only, two-way binding)
|
|
20
|
+
- MobX observation helpers
|
|
21
|
+
- **Benefit**: Code now reads like documentation with function names like `guardAgainstEchoLoop`, `createLazyValidatedSetter`, `safelyDisposeSubscription`
|
|
22
|
+
|
|
23
|
+
#### Improved circular reference handling in equality checks
|
|
24
|
+
- **Feature**: `isEqual()` now uses `Map` instead of `WeakSet` to track visited object pairs
|
|
25
|
+
- **Benefit**: Correctly compares objects where one has circular refs and the other doesn't
|
|
26
|
+
- **Previous bug**: `isEqual(circularObj, nonCircularObj)` incorrectly returned `true`
|
|
27
|
+
- **Fix**: Now tracks `(a, b)` pairs so revisiting `a` checks if it was paired with same `b`
|
|
28
|
+
|
|
29
|
+
### 🐛 Bug Fixes
|
|
30
|
+
|
|
31
|
+
#### Fixed deepObserve stale subscription after property reassignment
|
|
32
|
+
- **Issue**: When a property was reassigned to a new object/array, `deepObserve` continued watching the old value
|
|
33
|
+
- **Example**: After `store.items = newArray`, mutations to `newArray` weren't detected
|
|
34
|
+
- **Fix**: Added `onValueChanged` callback to `observeProperty` that re-subscribes `deepObserve` when property value changes
|
|
35
|
+
- **Result**: Nested mutations are now correctly detected even after complete property reassignment
|
|
36
|
+
|
|
37
|
+
#### Fixed -0 vs +0 edge case in equality comparison
|
|
38
|
+
- **Issue**: `Object.is(-0, +0)` returns `false`, causing unnecessary updates for equivalent values
|
|
39
|
+
- **Fix**: Added explicit number handling that treats `-0` and `+0` as equal while correctly handling `NaN === NaN`
|
|
40
|
+
|
|
41
|
+
#### Improved warning messages
|
|
42
|
+
- **Change**: Direct mutation warnings now include actionable guidance
|
|
43
|
+
- **Before**: `"Direct mutation of 'name' is disabled"`
|
|
44
|
+
- **After**: `"Direct mutation of 'name' is disabled. Use actions instead."`
|
|
45
|
+
|
|
46
|
+
### ✅ Testing
|
|
47
|
+
|
|
48
|
+
- **New test file**: `mobxVueBridgeBugVerification.test.js` - 10 tests verifying bug fixes
|
|
49
|
+
- Circular reference equality edge cases
|
|
50
|
+
- DeepObserve re-subscription after reassignment
|
|
51
|
+
- -0/+0 and NaN handling
|
|
52
|
+
- Single clone correctness
|
|
53
|
+
- Sibling nested array mutations
|
|
54
|
+
- Setter-only properties
|
|
55
|
+
- **New test file**: `mobxVueBridgeCrossClassComputed.test.js` - 3 tests for cross-class dependencies
|
|
56
|
+
- Computed properties depending on other class's computed properties
|
|
57
|
+
- Chain of three classes with computed dependencies
|
|
58
|
+
- Changes detected even when only bridging the dependent class
|
|
59
|
+
|
|
8
60
|
## [1.4.0] - 2025-11-25
|
|
9
61
|
|
|
10
62
|
### 🎯 Major Improvements
|
package/package.json
CHANGED
package/src/mobxVueBridge.js
CHANGED
|
@@ -5,6 +5,23 @@ import clone from 'clone';
|
|
|
5
5
|
import { categorizeMobxMembers } from './utils/memberDetection.js';
|
|
6
6
|
import { isEqual } from './utils/equality.js';
|
|
7
7
|
import { createDeepProxy } from './utils/deepProxy.js';
|
|
8
|
+
import {
|
|
9
|
+
safelyReadInitialValue,
|
|
10
|
+
createReactiveRef,
|
|
11
|
+
separateGetterSetterPairs,
|
|
12
|
+
findGettersOnly,
|
|
13
|
+
findSettersOnly,
|
|
14
|
+
defineReactiveProperty,
|
|
15
|
+
createLazyValidatedSetter,
|
|
16
|
+
createReadOnlySetter,
|
|
17
|
+
createWriteOnlySetter,
|
|
18
|
+
createTwoWayBindingSetter,
|
|
19
|
+
isCurrentlyUpdating,
|
|
20
|
+
observeProperty,
|
|
21
|
+
deepObserveProperty,
|
|
22
|
+
observeGetter,
|
|
23
|
+
safelyDisposeSubscription,
|
|
24
|
+
} from './utils/helpers.js';
|
|
8
25
|
|
|
9
26
|
/**
|
|
10
27
|
* 🌉 MobX-Vue Bridge
|
|
@@ -60,267 +77,185 @@ export function useMobxBridge(mobxObject, options = {}) {
|
|
|
60
77
|
const updatingFromVue = new Set();
|
|
61
78
|
|
|
62
79
|
// Warning helpers to reduce duplication
|
|
63
|
-
const warnDirectMutation = (prop) => console.warn(`Direct mutation of '${prop}' is disabled`);
|
|
64
|
-
const warnSetterMutation = (prop) => console.warn(`Direct mutation of setter '${prop}' is disabled`);
|
|
65
80
|
const warnMethodAssignment = (prop) => console.warn(`Cannot assign to method '${prop}'`);
|
|
66
81
|
|
|
67
|
-
// ---- properties (two-way)
|
|
82
|
+
// ---- Bridge observable properties (two-way binding) ----
|
|
68
83
|
const propertyRefs = {};
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
propertyRefs[
|
|
85
|
+
const bridgeObservableProperty = (propertyName) => {
|
|
86
|
+
propertyRefs[propertyName] = createReactiveRef(toJS(mobxObject[propertyName]));
|
|
72
87
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
set:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!isEqual(mobxObject[prop], cloned)) {
|
|
99
|
-
updatingFromVue.add(prop);
|
|
100
|
-
try {
|
|
101
|
-
mobxObject[prop] = cloned;
|
|
102
|
-
} finally {
|
|
103
|
-
updatingFromVue.delete(prop);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
: () => warnDirectMutation(prop),
|
|
108
|
-
enumerable: true,
|
|
109
|
-
configurable: true,
|
|
88
|
+
const createDeepProxyForValue = (value) => {
|
|
89
|
+
if (value && typeof value === 'object') {
|
|
90
|
+
return createDeepProxy(
|
|
91
|
+
value,
|
|
92
|
+
propertyName,
|
|
93
|
+
() => propertyRefs[propertyName].value,
|
|
94
|
+
allowDirectMutation,
|
|
95
|
+
updatingFromVue,
|
|
96
|
+
mobxObject,
|
|
97
|
+
propertyRefs
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
defineReactiveProperty(vueState, propertyName, {
|
|
104
|
+
get: () => createDeepProxyForValue(propertyRefs[propertyName].value),
|
|
105
|
+
set: createTwoWayBindingSetter({
|
|
106
|
+
propertyName,
|
|
107
|
+
target: mobxObject,
|
|
108
|
+
allowDirectMutation,
|
|
109
|
+
guardSet: updatingFromVue,
|
|
110
|
+
propertyRef: propertyRefs[propertyName],
|
|
111
|
+
// No deepProxyCreator needed - createTwoWayBindingSetter already clones the value
|
|
112
|
+
}),
|
|
110
113
|
});
|
|
114
|
+
};
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
members.properties.forEach(bridgeObservableProperty);
|
|
113
117
|
|
|
114
118
|
// ---- getters and setters (handle both computed and two-way binding) ------
|
|
115
119
|
const getterRefs = {};
|
|
116
120
|
const setterRefs = {};
|
|
117
121
|
const readOnlyDetected = new Set(); // Track properties detected as read-only on first write
|
|
118
122
|
|
|
119
|
-
//
|
|
120
|
-
const getterSetterPairs = members.getters
|
|
121
|
-
const gettersOnly = members.getters
|
|
122
|
-
const settersOnly = members.setters
|
|
123
|
+
// Categorize properties by their getter/setter combinations
|
|
124
|
+
const getterSetterPairs = separateGetterSetterPairs(members.getters, members.setters);
|
|
125
|
+
const gettersOnly = findGettersOnly(members.getters, members.setters);
|
|
126
|
+
const settersOnly = findSettersOnly(members.setters, members.getters);
|
|
123
127
|
|
|
124
|
-
//
|
|
128
|
+
// ---- Bridge getter/setter pairs (potentially writable computed properties) ----
|
|
125
129
|
// Note: Some may have MobX synthetic setters that throw - we detect this lazily on first write
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
initialValue = toJS(mobxObject[prop]);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
initialValue = undefined;
|
|
133
|
-
}
|
|
134
|
-
getterRefs[prop] = ref(initialValue);
|
|
130
|
+
const bridgeGetterSetterPair = (propertyName) => {
|
|
131
|
+
const initialValue = safelyReadInitialValue(mobxObject, propertyName);
|
|
132
|
+
getterRefs[propertyName] = createReactiveRef(initialValue);
|
|
135
133
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Try to call the MobX setter (first write test)
|
|
147
|
-
updatingFromVue.add(prop);
|
|
148
|
-
try {
|
|
149
|
-
mobxObject[prop] = value;
|
|
150
|
-
// The getter ref will be updated by the reaction
|
|
151
|
-
} catch (error) {
|
|
152
|
-
// Check if it's a MobX "not possible to assign" error (synthetic setter)
|
|
153
|
-
if (error.message && error.message.includes('not possible to assign')) {
|
|
154
|
-
// Mark as read-only so we don't try again
|
|
155
|
-
readOnlyDetected.add(prop);
|
|
156
|
-
// This is actually a read-only computed property
|
|
157
|
-
throw new Error(`Cannot assign to computed property '${prop}'`);
|
|
158
|
-
}
|
|
159
|
-
// For other errors (validation, side effects), just warn
|
|
160
|
-
console.warn(`Failed to set property '${prop}':`, error);
|
|
161
|
-
} finally {
|
|
162
|
-
updatingFromVue.delete(prop);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
: () => warnDirectMutation(prop),
|
|
166
|
-
enumerable: true,
|
|
167
|
-
configurable: true,
|
|
134
|
+
defineReactiveProperty(vueState, propertyName, {
|
|
135
|
+
get: () => getterRefs[propertyName].value,
|
|
136
|
+
set: createLazyValidatedSetter({
|
|
137
|
+
propertyName,
|
|
138
|
+
target: mobxObject,
|
|
139
|
+
allowDirectMutation,
|
|
140
|
+
readOnlySet: readOnlyDetected,
|
|
141
|
+
guardSet: updatingFromVue,
|
|
142
|
+
}),
|
|
168
143
|
});
|
|
169
|
-
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
getterSetterPairs.forEach(bridgeGetterSetterPair);
|
|
170
147
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
initialValue = toJS(mobxObject[prop]);
|
|
177
|
-
} catch (error) {
|
|
178
|
-
// If computed property throws during initialization (e.g., accessing null.property),
|
|
179
|
-
// set initial value to undefined and let the reaction handle updates later
|
|
180
|
-
initialValue = undefined;
|
|
181
|
-
}
|
|
182
|
-
getterRefs[prop] = ref(initialValue);
|
|
148
|
+
// ---- Bridge getter-only properties (read-only computed) ----
|
|
149
|
+
const bridgeGetterOnly = (propertyName) => {
|
|
150
|
+
const initialValue = safelyReadInitialValue(mobxObject, propertyName);
|
|
151
|
+
getterRefs[propertyName] = createReactiveRef(initialValue);
|
|
183
152
|
|
|
184
|
-
|
|
185
|
-
get: () => getterRefs[
|
|
186
|
-
set: ()
|
|
187
|
-
throw new Error(`Cannot assign to computed property '${prop}'`)
|
|
188
|
-
},
|
|
189
|
-
enumerable: true,
|
|
190
|
-
configurable: true,
|
|
153
|
+
defineReactiveProperty(vueState, propertyName, {
|
|
154
|
+
get: () => getterRefs[propertyName].value,
|
|
155
|
+
set: createReadOnlySetter(propertyName),
|
|
191
156
|
});
|
|
192
|
-
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
gettersOnly.forEach(bridgeGetterOnly);
|
|
193
160
|
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
setterRefs[prop] = ref(undefined);
|
|
161
|
+
// ---- Bridge setter-only properties (write-only) ----
|
|
162
|
+
const bridgeSetterOnly = (propertyName) => {
|
|
163
|
+
setterRefs[propertyName] = createReactiveRef(undefined);
|
|
198
164
|
|
|
199
|
-
|
|
200
|
-
get: () => setterRefs[
|
|
201
|
-
set:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
mobxObject[prop] = value;
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.warn(`Failed to set property '${prop}':`, error);
|
|
212
|
-
} finally {
|
|
213
|
-
updatingFromVue.delete(prop);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
: () => warnSetterMutation(prop),
|
|
217
|
-
enumerable: true,
|
|
218
|
-
configurable: true,
|
|
165
|
+
defineReactiveProperty(vueState, propertyName, {
|
|
166
|
+
get: () => setterRefs[propertyName].value,
|
|
167
|
+
set: createWriteOnlySetter({
|
|
168
|
+
propertyName,
|
|
169
|
+
target: mobxObject,
|
|
170
|
+
allowDirectMutation,
|
|
171
|
+
guardSet: updatingFromVue,
|
|
172
|
+
setterRef: setterRefs[propertyName],
|
|
173
|
+
}),
|
|
219
174
|
});
|
|
220
|
-
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
settersOnly.forEach(bridgeSetterOnly);
|
|
221
178
|
|
|
222
|
-
// ---- methods (bound)
|
|
223
|
-
|
|
179
|
+
// ---- Bridge methods (bound to MobX context) ----
|
|
180
|
+
const bridgeMethod = (methodName) => {
|
|
224
181
|
// Cache the bound method to avoid creating new functions on every access
|
|
225
|
-
const boundMethod = mobxObject[
|
|
226
|
-
|
|
182
|
+
const boundMethod = mobxObject[methodName].bind(mobxObject);
|
|
183
|
+
|
|
184
|
+
defineReactiveProperty(vueState, methodName, {
|
|
227
185
|
get: () => boundMethod,
|
|
228
|
-
set: () => warnMethodAssignment(
|
|
229
|
-
enumerable: true,
|
|
230
|
-
configurable: true,
|
|
186
|
+
set: () => warnMethodAssignment(methodName),
|
|
231
187
|
});
|
|
232
|
-
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
members.methods.forEach(bridgeMethod);
|
|
233
191
|
|
|
234
192
|
// ---- MobX → Vue: property observation ----------------------------------------
|
|
235
193
|
const subscriptions = [];
|
|
194
|
+
const deepObserveSubscriptions = {}; // Track deep observe subs per property for re-subscription
|
|
236
195
|
|
|
237
|
-
|
|
196
|
+
setupPropertyObservation();
|
|
197
|
+
setupGetterObservation();
|
|
238
198
|
|
|
239
|
-
//
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
updatingFromMobx.delete(prop);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
subscriptions.push(sub);
|
|
258
|
-
} catch (error) {
|
|
259
|
-
// Silently ignore non-observable properties
|
|
260
|
-
}
|
|
261
|
-
});
|
|
199
|
+
// Observe observable properties for MobX → Vue sync
|
|
200
|
+
function setupPropertyObservation() {
|
|
201
|
+
members.properties.forEach(propertyName => {
|
|
202
|
+
// Helper to setup/re-setup deep observation for a value
|
|
203
|
+
const setupDeepObserve = (value) => {
|
|
204
|
+
// Dispose existing deep observe subscription if any
|
|
205
|
+
if (deepObserveSubscriptions[propertyName]) {
|
|
206
|
+
safelyDisposeSubscription(deepObserveSubscriptions[propertyName]);
|
|
207
|
+
deepObserveSubscriptions[propertyName] = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Only deep observe objects and arrays
|
|
211
|
+
if (!value || typeof value !== 'object') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
262
214
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
const next = toJS(mobxObject[prop]);
|
|
275
|
-
if (!isEqual(propertyRefs[prop].value, next)) {
|
|
276
|
-
propertyRefs[prop].value = next;
|
|
277
|
-
}
|
|
278
|
-
} finally {
|
|
279
|
-
updatingFromMobx.delete(prop);
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
subscriptions.push(sub);
|
|
283
|
-
} catch (error) {
|
|
284
|
-
// Silently ignore if deepObserve fails (e.g., circular references in nested objects)
|
|
215
|
+
const deepObserveSub = deepObserveProperty({
|
|
216
|
+
target: mobxObject,
|
|
217
|
+
propertyName,
|
|
218
|
+
refToUpdate: propertyRefs[propertyName],
|
|
219
|
+
echoGuard: updatingFromVue,
|
|
220
|
+
updateGuard: updatingFromMobx,
|
|
221
|
+
});
|
|
222
|
+
if (deepObserveSub) {
|
|
223
|
+
deepObserveSubscriptions[propertyName] = deepObserveSub;
|
|
224
|
+
subscriptions.push(deepObserveSub);
|
|
285
225
|
}
|
|
286
|
-
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Observe direct property changes
|
|
229
|
+
const observeSub = observeProperty({
|
|
230
|
+
target: mobxObject,
|
|
231
|
+
propertyName,
|
|
232
|
+
refToUpdate: propertyRefs[propertyName],
|
|
233
|
+
echoGuard: updatingFromVue,
|
|
234
|
+
updateGuard: updatingFromMobx,
|
|
235
|
+
onValueChanged: setupDeepObserve, // Re-subscribe deepObserve when value changes
|
|
236
|
+
});
|
|
237
|
+
if (observeSub) subscriptions.push(observeSub);
|
|
238
|
+
|
|
239
|
+
// Initial deep observe setup
|
|
240
|
+
setupDeepObserve(mobxObject[propertyName]);
|
|
287
241
|
});
|
|
288
242
|
}
|
|
289
243
|
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
(next) => {
|
|
302
|
-
if (!getterRefs[prop]) return;
|
|
303
|
-
if (!isEqual(getterRefs[prop].value, next)) {
|
|
304
|
-
getterRefs[prop].value = next;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
);
|
|
308
|
-
subscriptions.push(sub);
|
|
309
|
-
});
|
|
244
|
+
// Observe computed properties (getters) for MobX → Vue sync
|
|
245
|
+
function setupGetterObservation() {
|
|
246
|
+
[...gettersOnly, ...getterSetterPairs].forEach(propertyName => {
|
|
247
|
+
const reactionSub = observeGetter({
|
|
248
|
+
target: mobxObject,
|
|
249
|
+
propertyName,
|
|
250
|
+
refToUpdate: getterRefs[propertyName],
|
|
251
|
+
});
|
|
252
|
+
subscriptions.push(reactionSub);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
310
255
|
|
|
311
|
-
// Cleanup
|
|
256
|
+
// Cleanup subscriptions when component unmounts
|
|
312
257
|
onUnmounted(() => {
|
|
313
|
-
subscriptions.forEach(
|
|
314
|
-
try {
|
|
315
|
-
if (typeof unsub === 'function') {
|
|
316
|
-
unsub();
|
|
317
|
-
} else if (typeof unsub?.dispose === 'function') {
|
|
318
|
-
unsub.dispose();
|
|
319
|
-
}
|
|
320
|
-
} catch (error) {
|
|
321
|
-
// Silently handle cleanup errors
|
|
322
|
-
}
|
|
323
|
-
});
|
|
258
|
+
subscriptions.forEach(safelyDisposeSubscription);
|
|
324
259
|
});
|
|
325
260
|
|
|
326
261
|
return vueState;
|
package/src/utils/equality.js
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deep equality comparison with circular reference protection.
|
|
3
3
|
*
|
|
4
|
-
* Uses
|
|
4
|
+
* Uses a Map to track visited object pairs and prevent infinite recursion.
|
|
5
5
|
* This is used to prevent unnecessary updates when values haven't actually changed.
|
|
6
6
|
*
|
|
7
7
|
* @param {any} a - First value to compare
|
|
8
8
|
* @param {any} b - Second value to compare
|
|
9
|
-
* @param {
|
|
9
|
+
* @param {Map} visited - Map of visited object pairs to prevent circular references
|
|
10
10
|
* @returns {boolean} True if values are deeply equal
|
|
11
11
|
*/
|
|
12
|
-
export function isEqual(a, b, visited = new
|
|
12
|
+
export function isEqual(a, b, visited = new Map()) {
|
|
13
|
+
// Handle null/undefined cases first
|
|
14
|
+
if (a == null || b == null) return a === b;
|
|
15
|
+
|
|
16
|
+
// Handle -0 vs +0 edge case (Object.is treats them as different)
|
|
17
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
18
|
+
// Both are numbers - use === which treats -0 and +0 as equal
|
|
19
|
+
// Special case for NaN: NaN === NaN is false, but we want true
|
|
20
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
21
|
+
return a === b;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
// Same reference or primitive equality
|
|
14
25
|
if (Object.is(a, b)) return true;
|
|
15
26
|
|
|
16
|
-
// Handle null/undefined cases
|
|
17
|
-
if (a == null || b == null) return a === b;
|
|
18
|
-
|
|
19
27
|
// Different types are not equal
|
|
20
28
|
if (typeof a !== typeof b) return false;
|
|
21
29
|
|
|
22
30
|
// For primitives, Object.is should have caught them
|
|
23
31
|
if (typeof a !== 'object') return false;
|
|
24
32
|
|
|
25
|
-
// Check for circular references
|
|
26
|
-
|
|
27
|
-
visited.
|
|
33
|
+
// Check for circular references - we need to track PAIRS of (a, b)
|
|
34
|
+
// Using a Map where keys are objects from 'a' and values are objects from 'b'
|
|
35
|
+
if (visited.has(a)) {
|
|
36
|
+
// We've seen 'a' before - check if it was paired with the same 'b'
|
|
37
|
+
return visited.get(a) === b;
|
|
38
|
+
}
|
|
39
|
+
visited.set(a, b);
|
|
28
40
|
|
|
29
41
|
// Fast array comparison
|
|
30
42
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -32,6 +44,9 @@ export function isEqual(a, b, visited = new WeakSet()) {
|
|
|
32
44
|
return a.every((val, i) => isEqual(val, b[i], visited));
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
// One is array, one is not
|
|
48
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
49
|
+
|
|
35
50
|
// Fast object comparison - check keys first
|
|
36
51
|
const aKeys = Object.keys(a);
|
|
37
52
|
const bKeys = Object.keys(b);
|
|
@@ -43,3 +58,4 @@ export function isEqual(a, b, visited = new WeakSet()) {
|
|
|
43
58
|
// Check values (recursive)
|
|
44
59
|
return aKeys.every(key => isEqual(a[key], b[key], visited));
|
|
45
60
|
}
|
|
61
|
+
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { ref } from 'vue';
|
|
2
|
+
import { toJS, observe, reaction } from 'mobx';
|
|
3
|
+
import { deepObserve } from 'mobx-utils';
|
|
4
|
+
import clone from 'clone';
|
|
5
|
+
import { isEqual } from './equality.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Declarative helper functions for the MobX-Vue bridge.
|
|
9
|
+
* These functions use human-oriented language to make the code self-documenting.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// VALUE READING & INITIALIZATION
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Safely reads the initial value of a property, returning undefined if it throws.
|
|
18
|
+
*/
|
|
19
|
+
export const safelyReadInitialValue = (object, propertyName) => {
|
|
20
|
+
try {
|
|
21
|
+
return toJS(object[propertyName]);
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a Vue reactive reference with an initial value.
|
|
29
|
+
*/
|
|
30
|
+
export const createReactiveRef = (initialValue) => ref(initialValue);
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// PROPERTY TYPE CATEGORIZATION
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Separates properties that have both getters and setters.
|
|
38
|
+
*/
|
|
39
|
+
export const separateGetterSetterPairs = (getters, setters) =>
|
|
40
|
+
getters.filter(prop => setters.includes(prop));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Finds getters that don't have corresponding setters (read-only computed).
|
|
44
|
+
*/
|
|
45
|
+
export const findGettersOnly = (getters, setters) =>
|
|
46
|
+
getters.filter(prop => !setters.includes(prop));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Finds setters that don't have corresponding getters (write-only).
|
|
50
|
+
*/
|
|
51
|
+
export const findSettersOnly = (setters, getters) =>
|
|
52
|
+
setters.filter(prop => !getters.includes(prop));
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// ECHO LOOP PREVENTION
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Executes an operation while preventing echo loops between Vue and MobX.
|
|
60
|
+
* Uses a guard set to track when Vue is updating MobX.
|
|
61
|
+
*/
|
|
62
|
+
export const guardAgainstEchoLoop = (propertyName, guardSet, operation) => {
|
|
63
|
+
guardSet.add(propertyName);
|
|
64
|
+
try {
|
|
65
|
+
operation();
|
|
66
|
+
} finally {
|
|
67
|
+
guardSet.delete(propertyName);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checks if a property is currently being updated (to prevent echo loops).
|
|
73
|
+
*/
|
|
74
|
+
export const isCurrentlyUpdating = (propertyName, guardSet) =>
|
|
75
|
+
guardSet.has(propertyName);
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// READ-ONLY DETECTION
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Checks if a property has been detected as read-only.
|
|
83
|
+
*/
|
|
84
|
+
export const isKnownReadOnly = (propertyName, readOnlySet) =>
|
|
85
|
+
readOnlySet.has(propertyName);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Marks a property as read-only (adds to set).
|
|
89
|
+
*/
|
|
90
|
+
export const markAsReadOnly = (propertyName, readOnlySet) => {
|
|
91
|
+
readOnlySet.add(propertyName);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Throws an error for read-only computed property assignment.
|
|
96
|
+
*/
|
|
97
|
+
export const throwReadOnlyError = (propertyName) => {
|
|
98
|
+
throw new Error(`Cannot assign to computed property '${propertyName}'`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Checks if an error is a MobX read-only computed property error.
|
|
103
|
+
*/
|
|
104
|
+
export const isMobxReadOnlyError = (error) =>
|
|
105
|
+
error.message?.includes('not possible to assign');
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// PROPERTY DEFINITION
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Defines a reactive property on the Vue state object.
|
|
113
|
+
*/
|
|
114
|
+
export const defineReactiveProperty = (vueState, propertyName, descriptor) => {
|
|
115
|
+
Object.defineProperty(vueState, propertyName, {
|
|
116
|
+
get: descriptor.get,
|
|
117
|
+
set: descriptor.set,
|
|
118
|
+
enumerable: true,
|
|
119
|
+
configurable: true,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// MUTATION WARNINGS
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Warns that direct mutation is disabled for a property.
|
|
129
|
+
*/
|
|
130
|
+
export const warnDirectMutation = (propertyName) => {
|
|
131
|
+
console.warn(`Direct mutation of '${propertyName}' is disabled. Use actions instead.`);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Logs a warning when a property fails to set.
|
|
136
|
+
*/
|
|
137
|
+
export const logSetterWarning = (propertyName, error) => {
|
|
138
|
+
console.warn(`Failed to set property '${propertyName}':`, error);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// SETTER CREATION
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Creates a validated setter that lazily detects read-only properties.
|
|
147
|
+
* This prevents calling setters during initialization (avoiding side effects).
|
|
148
|
+
*/
|
|
149
|
+
export const createLazyValidatedSetter = ({
|
|
150
|
+
propertyName,
|
|
151
|
+
target,
|
|
152
|
+
allowDirectMutation,
|
|
153
|
+
readOnlySet,
|
|
154
|
+
guardSet,
|
|
155
|
+
}) => {
|
|
156
|
+
if (!allowDirectMutation) {
|
|
157
|
+
return () => warnDirectMutation(propertyName);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (value) => {
|
|
161
|
+
// Fast-fail if already detected as read-only
|
|
162
|
+
if (isKnownReadOnly(propertyName, readOnlySet)) {
|
|
163
|
+
throwReadOnlyError(propertyName);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Attempt write with guard against echo loops
|
|
167
|
+
guardAgainstEchoLoop(propertyName, guardSet, () => {
|
|
168
|
+
try {
|
|
169
|
+
target[propertyName] = value;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (isMobxReadOnlyError(error)) {
|
|
172
|
+
markAsReadOnly(propertyName, readOnlySet);
|
|
173
|
+
throwReadOnlyError(propertyName);
|
|
174
|
+
} else {
|
|
175
|
+
logSetterWarning(propertyName, error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Creates a simple setter that always throws an error (for computed properties).
|
|
184
|
+
*/
|
|
185
|
+
export const createReadOnlySetter = (propertyName) => {
|
|
186
|
+
return () => {
|
|
187
|
+
throw new Error(`Cannot assign to computed property '${propertyName}'`);
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Creates a setter for write-only properties (no corresponding getter).
|
|
193
|
+
*/
|
|
194
|
+
export const createWriteOnlySetter = ({
|
|
195
|
+
propertyName,
|
|
196
|
+
target,
|
|
197
|
+
allowDirectMutation,
|
|
198
|
+
guardSet,
|
|
199
|
+
setterRef,
|
|
200
|
+
}) => {
|
|
201
|
+
if (!allowDirectMutation) {
|
|
202
|
+
return () => warnDirectMutation(propertyName);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (value) => {
|
|
206
|
+
setterRef.value = value;
|
|
207
|
+
guardAgainstEchoLoop(propertyName, guardSet, () => {
|
|
208
|
+
target[propertyName] = value;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// OBSERVABLE PROPERTY SETTERS
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Creates a two-way binding setter for observable properties.
|
|
219
|
+
*/
|
|
220
|
+
export const createTwoWayBindingSetter = ({
|
|
221
|
+
propertyName,
|
|
222
|
+
target,
|
|
223
|
+
allowDirectMutation,
|
|
224
|
+
guardSet,
|
|
225
|
+
propertyRef,
|
|
226
|
+
}) => {
|
|
227
|
+
if (!allowDirectMutation) {
|
|
228
|
+
return () => warnDirectMutation(propertyName);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (value) => {
|
|
232
|
+
if (!isEqual(propertyRef.value, value)) {
|
|
233
|
+
const cloned = clone(value);
|
|
234
|
+
propertyRef.value = cloned;
|
|
235
|
+
|
|
236
|
+
guardAgainstEchoLoop(propertyName, guardSet, () => {
|
|
237
|
+
target[propertyName] = cloned;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// MOBX OBSERVATION HELPERS
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Creates a MobX → Vue update handler that respects echo guards.
|
|
249
|
+
*/
|
|
250
|
+
export const createMobxToVueUpdater = ({
|
|
251
|
+
propertyName,
|
|
252
|
+
target,
|
|
253
|
+
refToUpdate,
|
|
254
|
+
echoGuard,
|
|
255
|
+
updateGuard,
|
|
256
|
+
}) => {
|
|
257
|
+
return () => {
|
|
258
|
+
if (!refToUpdate) return;
|
|
259
|
+
if (echoGuard.has(propertyName)) return; // Prevent echo loops
|
|
260
|
+
|
|
261
|
+
updateGuard.add(propertyName);
|
|
262
|
+
try {
|
|
263
|
+
const nextValue = toJS(target[propertyName]);
|
|
264
|
+
if (!isEqual(refToUpdate.value, nextValue)) {
|
|
265
|
+
refToUpdate.value = nextValue;
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
updateGuard.delete(propertyName);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Observes a single MobX property and syncs changes to Vue.
|
|
275
|
+
* When the property value changes, it also re-subscribes deepObserve to the new value.
|
|
276
|
+
*/
|
|
277
|
+
export const observeProperty = ({
|
|
278
|
+
target,
|
|
279
|
+
propertyName,
|
|
280
|
+
refToUpdate,
|
|
281
|
+
echoGuard,
|
|
282
|
+
updateGuard,
|
|
283
|
+
onValueChanged, // Optional callback when value changes (for re-subscribing deepObserve)
|
|
284
|
+
}) => {
|
|
285
|
+
try {
|
|
286
|
+
const updater = createMobxToVueUpdater({
|
|
287
|
+
propertyName,
|
|
288
|
+
target,
|
|
289
|
+
refToUpdate,
|
|
290
|
+
echoGuard,
|
|
291
|
+
updateGuard,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return observe(target, propertyName, (change) => {
|
|
295
|
+
updater();
|
|
296
|
+
// Notify that the value changed so deepObserve can be re-subscribed
|
|
297
|
+
if (onValueChanged && change.type === 'update') {
|
|
298
|
+
onValueChanged(change.newValue);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// Only silently ignore expected MobX errors for non-observable properties
|
|
303
|
+
// These errors indicate the property isn't observable, which is expected for some properties
|
|
304
|
+
const isExpectedError = error.message?.includes('not observable') ||
|
|
305
|
+
error.message?.includes('no observable') ||
|
|
306
|
+
error.message?.includes('[MobX]');
|
|
307
|
+
if (!isExpectedError) {
|
|
308
|
+
console.warn(`[mobx-vue-bridge] Unexpected error observing '${propertyName}':`, error);
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Deep observes nested objects/arrays and syncs changes to Vue.
|
|
316
|
+
*/
|
|
317
|
+
export const deepObserveProperty = ({
|
|
318
|
+
target,
|
|
319
|
+
propertyName,
|
|
320
|
+
refToUpdate,
|
|
321
|
+
echoGuard,
|
|
322
|
+
updateGuard,
|
|
323
|
+
}) => {
|
|
324
|
+
const value = target[propertyName];
|
|
325
|
+
|
|
326
|
+
// Only deep observe objects and arrays
|
|
327
|
+
if (!value || typeof value !== 'object') {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const updater = createMobxToVueUpdater({
|
|
333
|
+
propertyName,
|
|
334
|
+
target,
|
|
335
|
+
refToUpdate,
|
|
336
|
+
echoGuard,
|
|
337
|
+
updateGuard,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return deepObserve(value, (change, path) => {
|
|
341
|
+
updater();
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
// Only silently ignore expected errors (circular references, non-observable objects)
|
|
345
|
+
const isExpectedError = error.message?.includes('circular') ||
|
|
346
|
+
error.message?.includes('not observable') ||
|
|
347
|
+
error.message?.includes('[MobX]');
|
|
348
|
+
if (!isExpectedError) {
|
|
349
|
+
console.warn(`[mobx-vue-bridge] Unexpected error deep-observing '${propertyName}':`, error);
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Creates a reactive subscription to a MobX computed property (getter).
|
|
357
|
+
*/
|
|
358
|
+
export const observeGetter = ({
|
|
359
|
+
target,
|
|
360
|
+
propertyName,
|
|
361
|
+
refToUpdate,
|
|
362
|
+
}) => {
|
|
363
|
+
const safelyReadGetter = () => {
|
|
364
|
+
try {
|
|
365
|
+
return toJS(target[propertyName]);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// If computed property throws (e.g., accessing null.property), return undefined
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const updateRefWhenChanged = (nextValue) => {
|
|
373
|
+
if (!refToUpdate) return;
|
|
374
|
+
if (!isEqual(refToUpdate.value, nextValue)) {
|
|
375
|
+
refToUpdate.value = nextValue;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return reaction(safelyReadGetter, updateRefWhenChanged);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Safely disposes a subscription (handles both function and object with dispose).
|
|
384
|
+
*/
|
|
385
|
+
export const safelyDisposeSubscription = (subscription) => {
|
|
386
|
+
try {
|
|
387
|
+
if (typeof subscription === 'function') {
|
|
388
|
+
subscription();
|
|
389
|
+
} else if (typeof subscription?.dispose === 'function') {
|
|
390
|
+
subscription.dispose();
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// Silently handle cleanup errors
|
|
394
|
+
}
|
|
395
|
+
};
|