mobx-vue-bridge 1.3.0 → 1.4.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 CHANGED
@@ -5,6 +5,52 @@ 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.4.0] - 2025-11-25
9
+
10
+ ### 🎯 Major Improvements
11
+
12
+ #### Zero-side-effect initialization with lazy detection
13
+ - **Feature**: Bridge no longer calls any setters during initialization, eliminating all side effects
14
+ - **Benefit**: Fixes production bugs where setter side effects (like `refreshDataOnTabChange()`) were triggered during bridge setup
15
+ - **Mechanism**: Lazy detection pattern - properties are optimistically marked as writable during init, tested on first actual write attempt
16
+ - **Caching**: `readOnlyDetected` Set caches detection results for O(1) lookups on subsequent writes
17
+ - **Impact**: User's `currentRoom` setter with side effects now works correctly - side effects only occur during actual user interactions
18
+
19
+ #### Modular architecture refactoring
20
+ - **Feature**: Core logic separated into focused utility modules
21
+ - **Structure**:
22
+ - `src/utils/memberDetection.js` (210 lines) - MobX member categorization without side effects
23
+ - `src/utils/equality.js` (47 lines) - Deep equality with circular reference protection
24
+ - `src/utils/deepProxy.js` (109 lines) - Nested reactivity with microtask batching
25
+ - **Main bridge**: Reduced from 523 lines → 321 lines (39% reduction)
26
+ - **Benefit**: Better maintainability, testability, and code organization
27
+
28
+ ### 🐛 Bug Fixes
29
+
30
+ #### Fixed computed property detection with MobX synthetic setters
31
+ - **Issue**: MobX adds synthetic setters to computed-only properties that throw "not possible to assign" errors
32
+ - **Previous approach**: Tested setters during initialization (caused side effects)
33
+ - **New approach**: Check descriptor existence only (`descriptor.get && descriptor.set`), test lazily on first write
34
+ - **Detection**: Catch MobX error message during first write attempt, cache as read-only
35
+ - **Result**: Accurate detection without initialization side effects
36
+
37
+ #### Fixed test equality assertions
38
+ - **Issue**: One test expected reference equality for cloned objects
39
+ - **Fix**: Changed `.toBe()` to `.toStrictEqual()` for deep equality comparison
40
+ - **Context**: Bridge clones objects to prevent reference sharing between Vue and MobX
41
+
42
+ ### ✅ Testing
43
+
44
+ - **Total tests**: 170 passing (was 168 + 2 skipped)
45
+ - **New active tests**: Unskipped and fixed 2 comprehensive two-way binding demo tests
46
+ - **Coverage**: All patterns verified including lazy detection, nested mutations, and error handling
47
+
48
+ ### 📚 Documentation
49
+
50
+ - **Architecture notes**: Added inline documentation explaining lazy detection pattern
51
+ - **Comments**: Clear explanation of why setters aren't called during init
52
+ - **Examples**: Test files demonstrate proper usage patterns
53
+
8
54
  ## [1.2.0] - 2025-10-01
9
55
 
10
56
  ### ✨ New Features
package/README.md CHANGED
@@ -14,6 +14,8 @@ A seamless bridge between MobX observables and Vue 3's reactivity system, enabli
14
14
  - 🔒 **Type-safe bridging** between reactive systems
15
15
  - 🚀 **Optimized performance** with intelligent change detection
16
16
  - 🛡️ **Error handling** for edge cases and circular references
17
+ - ⚡ **Zero-side-effect initialization** with lazy detection (v1.4.0+)
18
+ - 📦 **Modular architecture** for better maintainability
17
19
 
18
20
  ## 📦 Installation
19
21
 
@@ -296,6 +298,51 @@ presenter.items.push(newItem)
296
298
  console.log(presenter.items) // Immediately updated!
297
299
  ```
298
300
 
301
+ ## 🏗️ Architecture & Implementation
302
+
303
+ ### Modular Design (v1.4.0+)
304
+
305
+ The bridge uses a clean, modular architecture for better maintainability:
306
+
307
+ ```
308
+ src/
309
+ ├── mobxVueBridge.js # Main bridge (321 lines)
310
+ └── utils/
311
+ ├── memberDetection.js # MobX property categorization (210 lines)
312
+ ├── equality.js # Deep equality with circular protection (47 lines)
313
+ └── deepProxy.js # Nested reactivity with batching (109 lines)
314
+ ```
315
+
316
+ ### Zero-Side-Effect Initialization
317
+
318
+ The bridge uses **lazy detection** to avoid calling setters during initialization:
319
+
320
+ ```javascript
321
+ class GuestPresenter {
322
+ get currentRoom() {
323
+ return this.repository.currentRoomId
324
+ }
325
+
326
+ set currentRoom(val) {
327
+ this.repository.currentRoomId = val
328
+ this.refreshDataOnTabChange() // Side effect!
329
+ }
330
+ }
331
+
332
+ // ✅ v1.4.0+: No side effects during bridge creation
333
+ const state = useMobxBridge(presenter) // refreshDataOnTabChange() NOT called
334
+
335
+ // ✅ Side effects only happen during actual mutations
336
+ state.currentRoom = 'room-123' // refreshDataOnTabChange() called here
337
+ ```
338
+
339
+ **How it works:**
340
+ 1. **Detection phase**: Checks descriptor existence only (`descriptor.get && descriptor.set`)
341
+ 2. **First write**: Tests if setter actually works by attempting the write
342
+ 3. **Caching**: Results stored in `readOnlyDetected` Set for O(1) future lookups
343
+
344
+ This prevents bugs where setter side effects were triggered during bridge setup, while maintaining accurate runtime behavior.
345
+
299
346
  ### Error Handling
300
347
  The bridge gracefully handles edge cases:
301
348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-vue-bridge",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A lightweight bridge for seamless two-way data binding between MobX observables and Vue 3 reactivity",
5
5
  "main": "src/mobxVueBridge.js",
6
6
  "module": "src/mobxVueBridge.js",
@@ -1,7 +1,10 @@
1
1
  import { reactive, onUnmounted, ref } from 'vue';
2
- import { toJS, reaction, observe, isComputedProp, isObservableProp } from 'mobx';
2
+ import { toJS, reaction, observe } from 'mobx';
3
3
  import { deepObserve } from 'mobx-utils';
4
4
  import clone from 'clone';
5
+ import { categorizeMobxMembers } from './utils/memberDetection.js';
6
+ import { isEqual } from './utils/equality.js';
7
+ import { createDeepProxy } from './utils/deepProxy.js';
5
8
 
6
9
  /**
7
10
  * 🌉 MobX-Vue Bridge
@@ -49,248 +52,18 @@ export function useMobxBridge(mobxObject, options = {}) {
49
52
 
50
53
  const vueState = reactive({});
51
54
 
52
- // Discover props/methods via MobX introspection (don’t rely on raw descriptors)
53
- const props = Object.getOwnPropertyNames(mobxObject)
54
- .concat(Object.getOwnPropertyNames(Object.getPrototypeOf(mobxObject)))
55
- .filter(p => p !== 'constructor' && !p.startsWith('_'));
55
+ // Use the imported categorization function
56
+ const members = categorizeMobxMembers(mobxObject);
56
57
 
57
- const members = {
58
- getters: props.filter(p => {
59
- try {
60
- // First try to check if it's a computed property via MobX introspection
61
- try {
62
- return isComputedProp(mobxObject, p);
63
- } catch (computedError) {
64
- // If isComputedProp fails (e.g., due to uninitialized nested objects),
65
- // fall back to checking if it has a getter descriptor
66
- const descriptor = Object.getOwnPropertyDescriptor(mobxObject, p) ||
67
- Object.getOwnPropertyDescriptor(Object.getPrototypeOf(mobxObject), p);
68
-
69
- // If it has a getter but no corresponding property, it's likely a computed getter
70
- return descriptor && typeof descriptor.get === 'function' &&
71
- !isObservableProp(mobxObject, p);
72
- }
73
- } catch (error) {
74
- return false;
75
- }
76
- }),
77
- setters: props.filter(p => {
78
- try {
79
- // Check if it has a setter descriptor
80
- const descriptor = Object.getOwnPropertyDescriptor(mobxObject, p) ||
81
- Object.getOwnPropertyDescriptor(Object.getPrototypeOf(mobxObject), p);
82
-
83
- // Must have a setter
84
- if (!descriptor || typeof descriptor.set !== 'function') return false;
85
-
86
- // Exclude methods
87
- if (typeof mobxObject[p] === 'function') return false;
88
-
89
- // For MobX objects with makeAutoObservable, we need to distinguish:
90
- // 1. Regular observable properties (handled separately)
91
- // 2. Computed properties with setters (getter/setter pairs)
92
- // 3. Setter-only properties
93
-
94
- // Include if it's a computed property with a WORKING setter (getter/setter pair)
95
- try {
96
- if (isComputedProp(mobxObject, p)) {
97
- // For computed properties, test if the setter actually works
98
- try {
99
- const originalValue = mobxObject[p];
100
- descriptor.set.call(mobxObject, originalValue); // Try to set to same value
101
- return true; // Setter works, it's a getter/setter pair
102
- } catch (setterError) {
103
- return false; // Setter throws error, it's a computed-only property
104
- }
105
- }
106
- } catch (error) {
107
- // If isComputedProp fails, check if it has a getter and test the setter
108
- if (descriptor.get) {
109
- try {
110
- // Try to get the current value and set it back
111
- const currentValue = mobxObject[p];
112
- descriptor.set.call(mobxObject, currentValue);
113
- return true; // Setter works
114
- } catch (setterError) {
115
- return false; // Setter throws error
116
- }
117
- }
118
- }
119
-
120
- // Include if it's NOT an observable property (setter-only or other cases)
121
- if (!isObservableProp(mobxObject, p)) return true;
122
-
123
- // Exclude regular observable properties (they're handled separately)
124
- return false;
125
- } catch (error) {
126
- return false;
127
- }
128
- }),
129
- properties: props.filter(p => {
130
- try {
131
- // Check if it's an observable property
132
- if (!isObservableProp(mobxObject, p)) return false;
133
-
134
- // Check if it's a function (method)
135
- if (typeof mobxObject[p] === 'function') return false;
136
-
137
- // Check if it's a computed property - if so, it's a getter, not a property
138
- const isComputed = isComputedProp(mobxObject, p);
139
- if (isComputed) return false;
140
-
141
- return true; // Regular observable property
142
- } catch (error) {
143
- return false;
144
- }
145
- }),
146
- methods: props.filter(p => {
147
- try {
148
- return typeof mobxObject[p] === 'function';
149
- } catch (error) {
150
- return false;
151
- }
152
- }),
153
- };
154
-
155
-
156
- // ---- utils: guards + equality --------------------------------------------
58
+ // ---- utils: guards -------------------------------------------------------
157
59
  const updatingFromMobx = new Set();
158
60
  const updatingFromVue = new Set();
159
61
 
160
- /**
161
- * Deep equality comparison with circular reference protection.
162
- * Uses WeakSet to track visited objects and prevent infinite recursion.
163
- *
164
- * @param {any} a - First value to compare
165
- * @param {any} b - Second value to compare
166
- * @param {WeakSet} visited - Set of visited objects to prevent circular references
167
- * @returns {boolean} True if values are deeply equal
168
- */
169
- const isEqual = (a, b, visited = new WeakSet()) => {
170
- if (Object.is(a, b)) return true;
171
-
172
- // Handle null/undefined cases
173
- if (a == null || b == null) return a === b;
174
-
175
- // Different types are not equal
176
- if (typeof a !== typeof b) return false;
177
-
178
- // For primitives, Object.is should have caught them
179
- if (typeof a !== 'object') return false;
180
-
181
- // Check for circular references
182
- if (visited.has(a)) return true;
183
- visited.add(a);
184
-
185
- // Fast array comparison
186
- if (Array.isArray(a) && Array.isArray(b)) {
187
- if (a.length !== b.length) return false;
188
- return a.every((val, i) => isEqual(val, b[i], visited));
189
- }
190
-
191
- // Fast object comparison - check keys first
192
- const aKeys = Object.keys(a);
193
- const bKeys = Object.keys(b);
194
- if (aKeys.length !== bKeys.length) return false;
195
-
196
- // Check if all keys match
197
- if (!aKeys.every(key => bKeys.includes(key))) return false;
198
-
199
- // Check values (recursive)
200
- return aKeys.every(key => isEqual(a[key], b[key], visited));
201
- };
202
-
203
62
  // Warning helpers to reduce duplication
204
63
  const warnDirectMutation = (prop) => console.warn(`Direct mutation of '${prop}' is disabled`);
205
64
  const warnSetterMutation = (prop) => console.warn(`Direct mutation of setter '${prop}' is disabled`);
206
65
  const warnMethodAssignment = (prop) => console.warn(`Cannot assign to method '${prop}'`);
207
66
 
208
- /**
209
- * Creates a deep proxy for nested objects/arrays to handle mutations at any level.
210
- * This enables mutations like state.items.push(item) to work correctly.
211
- * Respects the allowDirectMutation configuration for all nesting levels.
212
- *
213
- * Note: Mutations are batched via queueMicrotask to prevent corruption during
214
- * array operations like shift(), unshift(), splice() which modify multiple indices.
215
- * This ensures data correctness at the cost of a microtask delay.
216
- *
217
- * IMPORTANT: The proxy wraps the value stored in propertyRefs[prop].value, which is
218
- * a clone. When nested mutations occur, we update the clone in-place, then trigger
219
- * a sync back to MobX by re-assigning the entire cloned structure.
220
- *
221
- * @param {object|array} value - The nested value to wrap in a proxy
222
- * @param {string} prop - The parent property name for error messages and sync
223
- * @param {function} getRoot - Function that returns the current root value from propertyRef
224
- * @returns {Proxy} Proxied object/array with reactive mutation handling
225
- */
226
- const createDeepProxy = (value, prop, getRoot = null) => {
227
- // Don't proxy built-in objects that should remain unchanged
228
- if (value instanceof Date || value instanceof RegExp || value instanceof Map ||
229
- value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) {
230
- return value;
231
- }
232
-
233
- // If no getRoot provided, use the default which gets from propertyRefs
234
- if (!getRoot) {
235
- getRoot = () => propertyRefs[prop].value;
236
- }
237
-
238
- // Track pending updates to batch array mutations
239
- let updatePending = false;
240
-
241
- return new Proxy(value, {
242
- get: (target, key) => {
243
- const result = target[key];
244
- // If the result is an object/array, wrap it in a proxy too (but not built-ins)
245
- if (result && typeof result === 'object' &&
246
- !(result instanceof Date || result instanceof RegExp || result instanceof Map ||
247
- result instanceof Set || result instanceof WeakMap || result instanceof WeakSet)) {
248
- return createDeepProxy(result, prop, getRoot);
249
- }
250
- return result;
251
- },
252
- set: (target, key, val) => {
253
- // Check if direct mutation is allowed
254
- if (!allowDirectMutation) {
255
- warnDirectMutation(`${prop}.${String(key)}`);
256
- return true; // Must return true to avoid TypeError in strict mode
257
- }
258
-
259
- // Update the target in-place (this modifies the clone in propertyRefs[prop].value)
260
- target[key] = val;
261
-
262
- // Batch updates to avoid corrupting in-progress array operations
263
- // like shift(), unshift(), splice() which modify multiple indices synchronously
264
- if (!updatePending) {
265
- updatePending = true;
266
- queueMicrotask(() => {
267
- updatePending = false;
268
- // The root value has already been modified in-place above (target[key] = val)
269
- // Now we need to trigger Vue reactivity and sync to MobX
270
-
271
- // Clone the root to create a new reference for Vue reactivity
272
- // This ensures Vue detects the change
273
- const rootValue = getRoot();
274
- const cloned = clone(rootValue);
275
-
276
- // Update the Vue ref to trigger reactivity
277
- propertyRefs[prop].value = cloned;
278
-
279
- // Update MobX immediately with the cloned value
280
- updatingFromVue.add(prop);
281
- try {
282
- mobxObject[prop] = cloned;
283
- } finally {
284
- updatingFromVue.delete(prop);
285
- }
286
- });
287
- }
288
-
289
- return true;
290
- }
291
- });
292
- };
293
-
294
67
  // ---- properties (two-way) -------------------------------------------------
295
68
  const propertyRefs = {};
296
69
 
@@ -302,7 +75,15 @@ export function useMobxBridge(mobxObject, options = {}) {
302
75
  const value = propertyRefs[prop].value;
303
76
  // For objects/arrays, return a deep proxy that syncs mutations back
304
77
  if (value && typeof value === 'object') {
305
- return createDeepProxy(value, prop);
78
+ return createDeepProxy(
79
+ value,
80
+ prop,
81
+ () => propertyRefs[prop].value,
82
+ allowDirectMutation,
83
+ updatingFromVue,
84
+ mobxObject,
85
+ propertyRefs
86
+ );
306
87
  }
307
88
  return value;
308
89
  },
@@ -333,13 +114,15 @@ export function useMobxBridge(mobxObject, options = {}) {
333
114
  // ---- getters and setters (handle both computed and two-way binding) ------
334
115
  const getterRefs = {};
335
116
  const setterRefs = {};
117
+ const readOnlyDetected = new Set(); // Track properties detected as read-only on first write
336
118
 
337
119
  // First, handle properties that have BOTH getters and setters (getter/setter pairs)
338
120
  const getterSetterPairs = members.getters.filter(prop => members.setters.includes(prop));
339
121
  const gettersOnly = members.getters.filter(prop => !members.setters.includes(prop));
340
122
  const settersOnly = members.setters.filter(prop => !members.getters.includes(prop));
341
123
 
342
- // Getter/setter pairs: writable with reactive updates
124
+ // Getter/setter pairs: potentially writable with reactive updates
125
+ // Note: Some may have MobX synthetic setters that throw - we detect this lazily on first write
343
126
  getterSetterPairs.forEach(prop => {
344
127
  // Get initial value from getter
345
128
  let initialValue;
@@ -349,20 +132,31 @@ export function useMobxBridge(mobxObject, options = {}) {
349
132
  initialValue = undefined;
350
133
  }
351
134
  getterRefs[prop] = ref(initialValue);
352
- setterRefs[prop] = ref(initialValue);
353
135
 
354
136
  Object.defineProperty(vueState, prop, {
137
+ // ALWAYS read from MobX so computed properties work correctly
355
138
  get: () => getterRefs[prop].value,
356
139
  set: allowDirectMutation
357
140
  ? (value) => {
358
- // Update both refs
359
- setterRefs[prop].value = value;
360
- // Call the MobX setter immediately
141
+ // Check if we've already detected this as read-only
142
+ if (readOnlyDetected.has(prop)) {
143
+ throw new Error(`Cannot assign to computed property '${prop}'`);
144
+ }
145
+
146
+ // Try to call the MobX setter (first write test)
361
147
  updatingFromVue.add(prop);
362
148
  try {
363
149
  mobxObject[prop] = value;
364
150
  // The getter ref will be updated by the reaction
365
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
366
160
  console.warn(`Failed to set property '${prop}':`, error);
367
161
  } finally {
368
162
  updatingFromVue.delete(prop);
@@ -520,9 +314,11 @@ export function useMobxBridge(mobxObject, options = {}) {
520
314
  try {
521
315
  if (typeof unsub === 'function') {
522
316
  unsub();
317
+ } else if (typeof unsub?.dispose === 'function') {
318
+ unsub.dispose();
523
319
  }
524
- } catch {
525
- // Silently ignore cleanup errors
320
+ } catch (error) {
321
+ // Silently handle cleanup errors
526
322
  }
527
323
  });
528
324
  });
@@ -531,12 +327,7 @@ export function useMobxBridge(mobxObject, options = {}) {
531
327
  }
532
328
 
533
329
  /**
534
- * Helper alias for useMobxBridge - commonly used with presenter objects
535
- *
536
- * @param {object} presenter - The MobX presenter object to bridge
537
- * @param {object} options - Configuration options
538
- * @returns {object} Vue reactive state object
330
+ * Alias for useMobxBridge - for users who prefer "presenter" terminology
331
+ * @alias useMobxBridge
539
332
  */
540
- export function usePresenterState(presenter, options = {}) {
541
- return useMobxBridge(presenter, options);
542
- }
333
+ export const usePresenterState = useMobxBridge;
@@ -0,0 +1,109 @@
1
+ import clone from 'clone';
2
+
3
+ /**
4
+ * Creates a deep proxy for nested objects/arrays to handle mutations at any level.
5
+ *
6
+ * This enables mutations like `state.items.push(item)` to work correctly by:
7
+ * 1. Intercepting nested property access and wrapping in proxies
8
+ * 2. Batching mutations via queueMicrotask to prevent array corruption
9
+ * 3. Syncing changes back to MobX and triggering Vue reactivity
10
+ *
11
+ * Key Design Decisions:
12
+ * - Mutations are batched via queueMicrotask to prevent corruption during
13
+ * array operations like shift(), unshift(), splice() which modify multiple
14
+ * indices synchronously.
15
+ * - The proxy wraps a CLONE stored in propertyRefs[prop].value
16
+ * - When nested mutations occur, we update the clone in-place, then sync back
17
+ *
18
+ * @param {object|array} value - The nested value to wrap in a proxy
19
+ * @param {string} prop - The parent property name for error messages and sync
20
+ * @param {function} getRoot - Function that returns the current root value from propertyRef
21
+ * @param {boolean} allowDirectMutation - Whether mutations are allowed
22
+ * @param {Set} updatingFromVue - Guard set to prevent infinite loops
23
+ * @param {object} mobxObject - The MobX object to sync changes back to
24
+ * @param {object} propertyRefs - Vue refs storage for syncing
25
+ * @returns {Proxy} Proxied object/array with reactive mutation handling
26
+ */
27
+ export function createDeepProxy(
28
+ value,
29
+ prop,
30
+ getRoot,
31
+ allowDirectMutation,
32
+ updatingFromVue,
33
+ mobxObject,
34
+ propertyRefs
35
+ ) {
36
+ // Don't proxy built-in objects that should remain unchanged
37
+ if (value instanceof Date || value instanceof RegExp || value instanceof Map ||
38
+ value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) {
39
+ return value;
40
+ }
41
+
42
+ // If no getRoot provided, use the default which gets from propertyRefs
43
+ if (!getRoot) {
44
+ getRoot = () => propertyRefs[prop].value;
45
+ }
46
+
47
+ // Track pending updates to batch array mutations
48
+ let updatePending = false;
49
+
50
+ return new Proxy(value, {
51
+ get: (target, key) => {
52
+ const result = target[key];
53
+ // If the result is an object/array, wrap it in a proxy too (but not built-ins)
54
+ if (result && typeof result === 'object' &&
55
+ !(result instanceof Date || result instanceof RegExp || result instanceof Map ||
56
+ result instanceof Set || result instanceof WeakMap || result instanceof WeakSet)) {
57
+ return createDeepProxy(
58
+ result,
59
+ prop,
60
+ getRoot,
61
+ allowDirectMutation,
62
+ updatingFromVue,
63
+ mobxObject,
64
+ propertyRefs
65
+ );
66
+ }
67
+ return result;
68
+ },
69
+ set: (target, key, val) => {
70
+ // Check if direct mutation is allowed
71
+ if (!allowDirectMutation) {
72
+ console.warn(`Direct mutation of '${prop}.${String(key)}' is disabled`);
73
+ return true; // Must return true to avoid TypeError in strict mode
74
+ }
75
+
76
+ // Update the target in-place (this modifies the clone in propertyRefs[prop].value)
77
+ target[key] = val;
78
+
79
+ // Batch updates to avoid corrupting in-progress array operations
80
+ // like shift(), unshift(), splice() which modify multiple indices synchronously
81
+ if (!updatePending) {
82
+ updatePending = true;
83
+ queueMicrotask(() => {
84
+ updatePending = false;
85
+ // The root value has already been modified in-place above (target[key] = val)
86
+ // Now we need to trigger Vue reactivity and sync to MobX
87
+
88
+ // Clone the root to create a new reference for Vue reactivity
89
+ // This ensures Vue detects the change
90
+ const rootValue = getRoot();
91
+ const cloned = clone(rootValue);
92
+
93
+ // Update the Vue ref to trigger reactivity
94
+ propertyRefs[prop].value = cloned;
95
+
96
+ // Update MobX immediately with the cloned value
97
+ updatingFromVue.add(prop);
98
+ try {
99
+ mobxObject[prop] = cloned;
100
+ } finally {
101
+ updatingFromVue.delete(prop);
102
+ }
103
+ });
104
+ }
105
+
106
+ return true;
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deep equality comparison with circular reference protection.
3
+ *
4
+ * Uses WeakSet to track visited objects and prevent infinite recursion.
5
+ * This is used to prevent unnecessary updates when values haven't actually changed.
6
+ *
7
+ * @param {any} a - First value to compare
8
+ * @param {any} b - Second value to compare
9
+ * @param {WeakSet} visited - Set of visited objects to prevent circular references
10
+ * @returns {boolean} True if values are deeply equal
11
+ */
12
+ export function isEqual(a, b, visited = new WeakSet()) {
13
+ // Same reference or primitive equality
14
+ if (Object.is(a, b)) return true;
15
+
16
+ // Handle null/undefined cases
17
+ if (a == null || b == null) return a === b;
18
+
19
+ // Different types are not equal
20
+ if (typeof a !== typeof b) return false;
21
+
22
+ // For primitives, Object.is should have caught them
23
+ if (typeof a !== 'object') return false;
24
+
25
+ // Check for circular references
26
+ if (visited.has(a)) return true;
27
+ visited.add(a);
28
+
29
+ // Fast array comparison
30
+ if (Array.isArray(a) && Array.isArray(b)) {
31
+ if (a.length !== b.length) return false;
32
+ return a.every((val, i) => isEqual(val, b[i], visited));
33
+ }
34
+
35
+ // Fast object comparison - check keys first
36
+ const aKeys = Object.keys(a);
37
+ const bKeys = Object.keys(b);
38
+ if (aKeys.length !== bKeys.length) return false;
39
+
40
+ // Check if all keys match
41
+ if (!aKeys.every(key => bKeys.includes(key))) return false;
42
+
43
+ // Check values (recursive)
44
+ return aKeys.every(key => isEqual(a[key], b[key], visited));
45
+ }
@@ -0,0 +1,195 @@
1
+ import { isComputedProp, isObservableProp } from 'mobx';
2
+
3
+ /**
4
+ * Categorizes members of a MobX object into getters, setters, properties, and methods.
5
+ *
6
+ * This is the core classification logic that determines how each member should be
7
+ * bridged to Vue. The categorization affects:
8
+ * - Getters: Read-only computed properties
9
+ * - Setters: Writable computed properties (getter/setter pairs)
10
+ * - Properties: Two-way bindable observable properties
11
+ * - Methods: Bound functions
12
+ *
13
+ * @param {object} mobxObject - The MobX observable object to analyze
14
+ * @returns {object} Object with arrays: { getters, setters, properties, methods }
15
+ */
16
+ export function categorizeMobxMembers(mobxObject) {
17
+ // Discover all properties and methods (own + prototype)
18
+ const props = Object.getOwnPropertyNames(mobxObject)
19
+ .concat(Object.getOwnPropertyNames(Object.getPrototypeOf(mobxObject)))
20
+ .filter(p => p !== 'constructor' && !p.startsWith('_'));
21
+
22
+ return {
23
+ getters: detectGetters(mobxObject, props),
24
+ setters: detectSetters(mobxObject, props),
25
+ properties: detectProperties(mobxObject, props),
26
+ methods: detectMethods(mobxObject, props),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Detects computed properties (getters) in a MobX object.
32
+ *
33
+ * A property is considered a getter if:
34
+ * - MobX identifies it as a computed property via isComputedProp, OR
35
+ * - It has a getter descriptor but is not an observable property
36
+ *
37
+ * @param {object} mobxObject - The MobX object
38
+ * @param {string[]} props - Array of property names to check
39
+ * @returns {string[]} Array of getter property names
40
+ */
41
+ function detectGetters(mobxObject, props) {
42
+ return props.filter(p => {
43
+ try {
44
+ // First try MobX introspection
45
+ try {
46
+ return isComputedProp(mobxObject, p);
47
+ } catch (computedError) {
48
+ // If isComputedProp fails (e.g., uninitialized nested objects),
49
+ // fall back to descriptor checking
50
+ const descriptor = getDescriptor(mobxObject, p);
51
+
52
+ // Has getter but not an observable property = computed getter
53
+ return descriptor &&
54
+ typeof descriptor.get === 'function' &&
55
+ !isObservableProp(mobxObject, p);
56
+ }
57
+ } catch (error) {
58
+ return false;
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Detects writable computed properties (getter/setter pairs) in a MobX object.
65
+ *
66
+ * A property is considered a setter if:
67
+ * - It has a setter descriptor, AND
68
+ * - It's either a computed property with a working setter OR a setter-only property
69
+ *
70
+ * Key Challenge: Testing if a setter "works" can have side effects.
71
+ * Current approach: Try to set the property to its current value.
72
+ * TODO: Consider safer detection methods or document limitations.
73
+ *
74
+ * @param {object} mobxObject - The MobX object
75
+ * @param {string[]} props - Array of property names to check
76
+ * @returns {string[]} Array of setter property names
77
+ */
78
+ function detectSetters(mobxObject, props) {
79
+ return props.filter(p => {
80
+ try {
81
+ const descriptor = getDescriptor(mobxObject, p);
82
+
83
+ // Must have a setter
84
+ if (!descriptor || typeof descriptor.set !== 'function') return false;
85
+
86
+ // Exclude methods (shouldn't happen, but defensive)
87
+ if (typeof mobxObject[p] === 'function') return false;
88
+
89
+ // For computed properties, include if it has both getter AND setter descriptors
90
+ // Don't call the setter during initialization - we'll handle errors during actual sync
91
+ try {
92
+ if (isComputedProp(mobxObject, p)) {
93
+ // If both getter and setter exist, assume it's potentially writable
94
+ // The actual setter behavior will be tested lazily during first sync attempt
95
+ return descriptor.get && descriptor.set;
96
+ }
97
+ } catch (error) {
98
+ // If isComputedProp fails, check if both descriptors exist
99
+ if (descriptor.get && descriptor.set) {
100
+ return true;
101
+ }
102
+ }
103
+
104
+ // Include setter-only properties (not observable)
105
+ if (!isObservableProp(mobxObject, p)) return true;
106
+
107
+ // Exclude regular observable properties (handled separately)
108
+ return false;
109
+ } catch (error) {
110
+ return false;
111
+ }
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Tests if a setter can be called without throwing.
117
+ *
118
+ * CAUTION: This has side effects if the setter triggers actions or state changes.
119
+ * The test tries to set the property to its current value to minimize impact.
120
+ *
121
+ * @param {object} mobxObject - The MobX object
122
+ * @param {PropertyDescriptor} descriptor - The property descriptor
123
+ * @param {string} prop - Property name (for error context)
124
+ * @returns {boolean} True if setter works without throwing
125
+ */
126
+ function testSetter(mobxObject, descriptor, prop) {
127
+ try {
128
+ const originalValue = mobxObject[prop];
129
+ descriptor.set.call(mobxObject, originalValue);
130
+ return true; // Setter works
131
+ } catch (setterError) {
132
+ // Setter throws - treat as read-only computed
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Detects regular observable properties in a MobX object.
139
+ *
140
+ * A property is considered a regular observable if:
141
+ * - MobX identifies it as observable via isObservableProp, AND
142
+ * - It's not a function (method), AND
143
+ * - It's not a computed property
144
+ *
145
+ * @param {object} mobxObject - The MobX object
146
+ * @param {string[]} props - Array of property names to check
147
+ * @returns {string[]} Array of observable property names
148
+ */
149
+ function detectProperties(mobxObject, props) {
150
+ return props.filter(p => {
151
+ try {
152
+ // Must be observable
153
+ if (!isObservableProp(mobxObject, p)) return false;
154
+
155
+ // Exclude methods
156
+ if (typeof mobxObject[p] === 'function') return false;
157
+
158
+ // Exclude computed properties (they're getters)
159
+ if (isComputedProp(mobxObject, p)) return false;
160
+
161
+ return true; // Regular observable property
162
+ } catch (error) {
163
+ return false;
164
+ }
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Detects methods in a MobX object.
170
+ *
171
+ * @param {object} mobxObject - The MobX object
172
+ * @param {string[]} props - Array of property names to check
173
+ * @returns {string[]} Array of method names
174
+ */
175
+ function detectMethods(mobxObject, props) {
176
+ return props.filter(p => {
177
+ try {
178
+ return typeof mobxObject[p] === 'function';
179
+ } catch (error) {
180
+ return false;
181
+ }
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Gets the property descriptor from either the object or its prototype.
187
+ *
188
+ * @param {object} obj - The object to inspect
189
+ * @param {string} prop - Property name
190
+ * @returns {PropertyDescriptor|undefined} The descriptor or undefined
191
+ */
192
+ function getDescriptor(obj, prop) {
193
+ return Object.getOwnPropertyDescriptor(obj, prop) ||
194
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(obj), prop);
195
+ }