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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-vue-bridge",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -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
- members.properties.forEach(prop => {
71
- propertyRefs[prop] = ref(toJS(mobxObject[prop]));
85
+ const bridgeObservableProperty = (propertyName) => {
86
+ propertyRefs[propertyName] = createReactiveRef(toJS(mobxObject[propertyName]));
72
87
 
73
- Object.defineProperty(vueState, prop, {
74
- get: () => {
75
- const value = propertyRefs[prop].value;
76
- // For objects/arrays, return a deep proxy that syncs mutations back
77
- if (value && typeof value === 'object') {
78
- return createDeepProxy(
79
- value,
80
- prop,
81
- () => propertyRefs[prop].value,
82
- allowDirectMutation,
83
- updatingFromVue,
84
- mobxObject,
85
- propertyRefs
86
- );
87
- }
88
- return value;
89
- },
90
- set: allowDirectMutation
91
- ? (value) => {
92
- // Update Vue ref
93
- const cloned = clone(value);
94
- if (!isEqual(propertyRefs[prop].value, cloned)) {
95
- propertyRefs[prop].value = cloned;
96
- }
97
- // ALSO update MobX immediately (synchronous)
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
- // First, handle properties that have BOTH getters and setters (getter/setter pairs)
120
- const getterSetterPairs = members.getters.filter(prop => members.setters.includes(prop));
121
- const gettersOnly = members.getters.filter(prop => !members.setters.includes(prop));
122
- const settersOnly = members.setters.filter(prop => !members.getters.includes(prop));
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
- // Getter/setter pairs: potentially writable with reactive updates
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
- getterSetterPairs.forEach(prop => {
127
- // Get initial value from getter
128
- let initialValue;
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
- Object.defineProperty(vueState, prop, {
137
- // ALWAYS read from MobX so computed properties work correctly
138
- get: () => getterRefs[prop].value,
139
- set: allowDirectMutation
140
- ? (value) => {
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)
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
- // Getter-only properties: read-only computed
172
- gettersOnly.forEach(prop => {
173
- // Safely get initial value of computed property, handle errors gracefully
174
- let initialValue;
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
- Object.defineProperty(vueState, prop, {
185
- get: () => getterRefs[prop].value,
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
- // Setter-only properties: write-only
195
- settersOnly.forEach(prop => {
196
- // For setter-only properties, track the last set value
197
- setterRefs[prop] = ref(undefined);
161
+ // ---- Bridge setter-only properties (write-only) ----
162
+ const bridgeSetterOnly = (propertyName) => {
163
+ setterRefs[propertyName] = createReactiveRef(undefined);
198
164
 
199
- Object.defineProperty(vueState, prop, {
200
- get: () => setterRefs[prop].value,
201
- set: allowDirectMutation
202
- ? (value) => {
203
- // Update the setter ref
204
- setterRefs[prop].value = value;
205
-
206
- // Call the MobX setter immediately
207
- updatingFromVue.add(prop);
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
- members.methods.forEach(prop => {
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[prop].bind(mobxObject);
226
- Object.defineProperty(vueState, prop, {
182
+ const boundMethod = mobxObject[methodName].bind(mobxObject);
183
+
184
+ defineReactiveProperty(vueState, methodName, {
227
185
  get: () => boundMethod,
228
- set: () => warnMethodAssignment(prop),
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
- setupStandardPropertyObservers();
196
+ setupPropertyObservation();
197
+ setupGetterObservation();
238
198
 
239
- // Standard property observation implementation
240
- function setupStandardPropertyObservers() {
241
- // Use individual observe for each property to avoid circular reference issues
242
- members.properties.forEach(prop => {
243
- try {
244
- const sub = observe(mobxObject, prop, (change) => {
245
- if (!propertyRefs[prop]) return;
246
- if (updatingFromVue.has(prop)) return; // avoid echo
247
- updatingFromMobx.add(prop);
248
- try {
249
- const next = toJS(mobxObject[prop]);
250
- if (!isEqual(propertyRefs[prop].value, next)) {
251
- propertyRefs[prop].value = next;
252
- }
253
- } finally {
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
- // For nested objects and arrays, use deepObserve to handle deep changes
264
- // This handles both object properties and array mutations
265
- members.properties.forEach(prop => {
266
- const value = mobxObject[prop];
267
- if (value && typeof value === 'object') { // Include both objects AND arrays
268
- try {
269
- const sub = deepObserve(value, (change, path) => {
270
- if (!propertyRefs[prop]) return;
271
- if (updatingFromVue.has(prop)) return; // avoid echo
272
- updatingFromMobx.add(prop);
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
- // Getters: keep them in sync via reaction (both getter-only and getter/setter pairs)
291
- [...gettersOnly, ...getterSetterPairs].forEach(prop => {
292
- const sub = reaction(
293
- () => {
294
- try {
295
- return toJS(mobxObject[prop]);
296
- } catch (error) {
297
- // If computed property throws (e.g., accessing null.property), return undefined
298
- return undefined;
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(unsub => {
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;
@@ -1,30 +1,42 @@
1
1
  /**
2
2
  * Deep equality comparison with circular reference protection.
3
3
  *
4
- * Uses WeakSet to track visited objects and prevent infinite recursion.
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 {WeakSet} visited - Set of visited objects to prevent circular references
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 WeakSet()) {
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
- if (visited.has(a)) return true;
27
- visited.add(a);
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
+ };