mobx-vue-bridge 1.0.3 β†’ 1.2.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,79 @@ 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.2.0] - 2025-10-01
9
+
10
+ ### ✨ New Features
11
+
12
+ #### Batched nested mutations for array correctness
13
+ - **Feature**: Nested mutations are now batched via `queueMicrotask()` to prevent array corruption
14
+ - **Benefit**: All array operations (`shift()`, `unshift()`, `splice()`, etc.) now work correctly without data corruption
15
+ - **Trade-off**: Nested mutations are async (microtask delay). Use `await nextTick()` for immediate reads in same function
16
+ - **Best Practice**: Keep logic in MobX Presenter = always synchronous, no `nextTick()` needed
17
+ - **Documentation**: Comprehensive guide in README.md with examples and patterns
18
+ - **Tests**: 7 new tests in `mobxVueBridgeArrayCorrectness.test.js` verifying correctness
19
+ - **Total Test Count**: 148 tests passing (141 original + 7 new)
20
+
21
+ ### πŸ› Bug Fixes
22
+
23
+ #### Fixed array mutation corruption
24
+ - **Issue**: Array methods like `shift()`, `unshift()`, and `splice()` were corrupting arrays during nested mutations. For example, `shift()` on `[1,2,3]` would produce `[2,2,3]` instead of `[2,3]`
25
+ - **Root Cause**: Each index assignment during array operations triggered an immediate `clone()` + sync, interrupting the in-progress array method
26
+ - **Impact**: Array mutations through nested proxies produced incorrect results, breaking data integrity
27
+ - **Fix**: Implemented `queueMicrotask()` batching in `createDeepProxy` to defer updates until array operations complete. Uses `updatePending` flag to batch multiple mutations into a single update
28
+ - **Location**: Line ~230-270 in `createDeepProxy` function
29
+ - **Result**: All array methods now work correctly without corruption
30
+
31
+ ## [1.1.0] - 2025-10-01
32
+
33
+ #### Fixed `updatingFromVue` guard implementation
34
+ - **Issue**: The `updatingFromVue` Set was declared but never populated, making it a dead guard that didn't prevent potential echo loops
35
+ - **Impact**: Potential for echo loops in edge cases with custom objects or special equality scenarios
36
+ - **Fix**: Properly implemented the guard by wrapping all Vue β†’ MobX write operations with `updatingFromVue.add(prop)` and `updatingFromVue.delete(prop)` in try/finally blocks
37
+ - **Locations**: Property setters (line ~224), nested proxy setters (line ~195), getter/setter pairs (line ~253), and setter-only properties (line ~310)
38
+ - **Result**: Double protection against loops - primary via `isEqual` checks, secondary via `updatingFromVue` guard
39
+
40
+ #### Fixed nested proxy respecting `allowDirectMutation` flag
41
+ - **Issue**: When `allowDirectMutation: false` was set, top-level mutations were correctly blocked, but nested mutations (e.g., `state.user.name = 'Alice'` or `state.todos.push(item)`) bypassed the configuration and succeeded
42
+ - **Impact**: Configuration inconsistency - users couldn't enforce action-only mutation patterns for nested data
43
+ - **Fix**: Added `allowDirectMutation` check at the top of the `createDeepProxy` set trap, with proper warning messages including the full nested path (e.g., `"Direct mutation of 'user.name' is disabled"`)
44
+ - **Location**: Line ~182 in `createDeepProxy` function
45
+ - **Result**: The `allowDirectMutation` flag now consistently applies to all nesting levels
46
+
47
+ #### Added parameter validation for `mobxObject`
48
+ - **Issue**: No validation on the first parameter, leading to cryptic errors if users passed `null`, `undefined`, or non-objects
49
+ - **Impact**: Poor developer experience with unclear error messages deep in the code
50
+ - **Fix**: Added validation at function entry that throws a clear error: `"useMobxBridge requires a valid MobX observable object as the first parameter"`
51
+ - **Location**: Line ~15, immediately after function declaration
52
+ - **Result**: Fail-fast behavior with actionable error messages
53
+
54
+ #### Fixed circular reference handling in `isEqual`
55
+ - **Issue**: The deep equality function could enter infinite recursion when comparing objects with shared references or circular structures
56
+ - **Impact**: Stack overflow errors when working with complex data structures like tree nodes, graphs, or objects with bidirectional relationships
57
+ - **Fix**: Added `WeakSet` to track visited objects during comparison, preventing infinite recursion while maintaining proper garbage collection
58
+ - **Location**: Line ~135 in `isEqual` function
59
+ - **Result**: Can safely compare objects with shared references, deeply nested structures (100+ levels), and graph-like data
60
+
61
+ ### βœ… Testing
62
+ - Added 14 new tests (7 for validation, 7 for circular references)
63
+ - Updated existing tests to handle async nested mutations with `nextTick()`
64
+ - All 141 tests passing (127 original + 14 new)
65
+ - No breaking changes to public API
66
+ - Backward compatible with existing code
67
+
68
+ ### πŸ“ Documentation
69
+ - Updated README with async behavior notes for nested mutations
70
+ - Added example showing `nextTick()` usage for immediate value access
71
+ - Updated inline code comments for clarity
72
+ - Added JSDoc comments for better IDE integration
73
+
74
+ ---
75
+
76
+ **Upgrade recommendation**: Recommended for all users. Especially important for:
77
+ - Users using `allowDirectMutation: false` with nested data
78
+ - Applications with complex object graphs or shared references
79
+ - Teams seeking better error messages for debugging
80
+
8
81
  ## [1.0.0] - 2025-09-29
9
82
 
10
83
  ### Added
package/README.md CHANGED
@@ -215,6 +215,33 @@ const state = useMobxBridge(appStore)
215
215
 
216
216
  ## πŸ”§ Advanced Features
217
217
 
218
+ ### Configuration Options
219
+
220
+ The bridge accepts an optional configuration object to customize its behavior:
221
+
222
+ ```javascript
223
+ const state = useMobxBridge(mobxObject, {
224
+ allowDirectMutation: true // default: true
225
+ })
226
+ ```
227
+
228
+ #### `allowDirectMutation` (boolean)
229
+ Controls whether direct mutations are allowed on the Vue state:
230
+ - `true` (default): Allows `state.name = 'New Name'`
231
+ - `false`: Mutations must go through MobX actions
232
+
233
+ ```javascript
234
+ // Allow direct mutations (default)
235
+ const state = useMobxBridge(presenter, { allowDirectMutation: true })
236
+ state.name = 'John' // βœ… Works
237
+
238
+ // Disable direct mutations (action-only mode)
239
+ const state = useMobxBridge(presenter, { allowDirectMutation: false })
240
+ state.name = 'John' // ❌ Warning: use actions instead
241
+ presenter.setName('John') // βœ… Works
242
+ ```
243
+ - βœ… You can use `await nextTick()` when needed for immediate reads
244
+
218
245
  ### Deep Reactivity
219
246
  The bridge automatically handles deep changes in objects and arrays:
220
247
 
@@ -225,6 +252,50 @@ state.todos.push(newTodo) // Array mutation
225
252
  state.settings.colors[0] = '#FF0000' // Nested array mutation
226
253
  ```
227
254
 
255
+ **Note on Async Behavior:** Nested mutations (via the deep proxy) are batched using `queueMicrotask()` to prevent corruption during array operations like `shift()`, `unshift()`, and `splice()`. This ensures data correctness. If you need immediate access to updated values after nested mutations in the same function, use Vue's `nextTick()`:
256
+
257
+ ```javascript
258
+ import { nextTick } from 'vue'
259
+
260
+ state.items.push(newItem)
261
+ await nextTick() // Wait for batched update to complete
262
+ console.log(state.items) // Now updated
263
+ ```
264
+
265
+ **However, Vue templates, computed properties, and watchers work automatically without `nextTick()`:**
266
+
267
+ ```vue
268
+ <template>
269
+ <!-- Auto-updates, no nextTick needed -->
270
+ <div>{{ state.items.length }}</div>
271
+ </template>
272
+
273
+ <script setup>
274
+ // Computed auto-updates, no nextTick needed
275
+ const itemCount = computed(() => state.items.length)
276
+
277
+ // Watcher auto-fires, no nextTick needed
278
+ watch(() => state.items, (newItems) => {
279
+ console.log('Items changed:', newItems)
280
+ })
281
+ </script>
282
+ ```
283
+
284
+ Top-level property assignments are synchronous:
285
+ ```javascript
286
+ state.count = 42 // Immediate (sync)
287
+ state.items = [1, 2, 3] // Immediate (sync)
288
+ state.items.push(4) // Batched (async - requires nextTick for immediate read)
289
+ ```
290
+
291
+ **Best Practice:** Keep business logic in your MobX Presenter. When you mutate via the Presenter, everything is synchronous:
292
+
293
+ ```javascript
294
+ // βœ… Presenter pattern - always synchronous, no nextTick needed
295
+ presenter.items.push(newItem)
296
+ console.log(presenter.items) // Immediately updated!
297
+ ```
298
+
228
299
  ### Error Handling
229
300
  The bridge gracefully handles edge cases:
230
301
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mobx-vue-bridge",
3
- "version": "1.0.3",
4
- "description": "A bridge between MobX observables and Vue 3 reactivity system, enabling seamless two-way data binding and state synchronization.",
3
+ "version": "1.2.0",
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",
7
7
  "types": "src/mobxVueBridge.d.ts",
@@ -22,7 +22,11 @@
22
22
  "test": "vitest",
23
23
  "test:watch": "vitest --watch",
24
24
  "test:coverage": "vitest --coverage",
25
- "prepublishOnly": "npm test && node scripts/pre-publish.js",
25
+ "test:run": "vitest --run",
26
+ "lint": "echo 'No linter configured yet. Consider adding ESLint.'",
27
+ "format": "echo 'No formatter configured yet. Consider adding Prettier.'",
28
+ "validate": "npm run test:run",
29
+ "prepublishOnly": "npm run validate && node scripts/pre-publish.js",
26
30
  "publish:dry": "npm publish --dry-run"
27
31
  },
28
32
  "repository": {
@@ -47,6 +51,10 @@
47
51
  "url": "https://github.com/visaruruqi/mobx-vue-bridge/issues"
48
52
  },
49
53
  "homepage": "https://github.com/visaruruqi/mobx-vue-bridge#readme",
54
+ "funding": {
55
+ "type": "github",
56
+ "url": "https://github.com/sponsors/visaruruqi"
57
+ },
50
58
  "peerDependencies": {
51
59
  "vue": "^3.0.0",
52
60
  "mobx": "^6.0.0"
@@ -6,17 +6,47 @@ import clone from 'clone';
6
6
  /**
7
7
  * πŸŒ‰ MobX-Vue Bridge
8
8
  *
9
- * @param {object} mobxObject - The MobX observable object to bridge
9
+ * Creates a bidirectional bridge between MobX observables and Vue 3 reactivity.
10
+ * Automatically synchronizes changes in both directions while preventing infinite loops.
11
+ *
12
+ * @param {object} mobxObject - The MobX observable object to bridge (created with makeAutoObservable)
10
13
  * @param {object} options - Configuration options
11
- * @param {boolean} options.allowDirectMutation - Whether to allow direct mutation of properties
12
- * @returns {object} Vue reactive state object
14
+ * @param {boolean} options.allowDirectMutation - Whether to allow direct mutation of properties (default: true)
15
+ * @returns {object} Vue reactive state object with synchronized properties, getters, setters, and methods
16
+ *
17
+ * @example
18
+ * ```javascript
19
+ * import { useMobxBridge } from 'mobx-vue-bridge'
20
+ * import { makeAutoObservable } from 'mobx'
21
+ *
22
+ * class UserStore {
23
+ * constructor() {
24
+ * this.name = 'John'
25
+ * this.age = 30
26
+ * makeAutoObservable(this)
27
+ * }
28
+ *
29
+ * get displayName() {
30
+ * return `${this.name} (${this.age})`
31
+ * }
32
+ * }
33
+ *
34
+ * const store = new UserStore()
35
+ * const state = useMobxBridge(store)
36
+ * ```
13
37
  */
14
38
  export function useMobxBridge(mobxObject, options = {}) {
39
+ // Validate mobxObject parameter
40
+ if (!mobxObject || typeof mobxObject !== 'object') {
41
+ throw new Error('useMobxBridge requires a valid MobX observable object as the first parameter');
42
+ }
43
+
15
44
  const safeOptions = options || {};
16
45
  // Use explicit boolean conversion to handle truthy/falsy values properly
17
46
  const allowDirectMutation = safeOptions.allowDirectMutation !== undefined
18
47
  ? Boolean(safeOptions.allowDirectMutation)
19
48
  : true; // Keep the original default of true
49
+
20
50
  const vueState = reactive({});
21
51
 
22
52
  // Discover props/methods via MobX introspection (don’t rely on raw descriptors)
@@ -127,7 +157,16 @@ export function useMobxBridge(mobxObject, options = {}) {
127
157
  const updatingFromMobx = new Set();
128
158
  const updatingFromVue = new Set();
129
159
 
130
- const isEqual = (a, b) => {
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()) => {
131
170
  if (Object.is(a, b)) return true;
132
171
 
133
172
  // Handle null/undefined cases
@@ -139,10 +178,14 @@ export function useMobxBridge(mobxObject, options = {}) {
139
178
  // For primitives, Object.is should have caught them
140
179
  if (typeof a !== 'object') return false;
141
180
 
181
+ // Check for circular references
182
+ if (visited.has(a)) return true;
183
+ visited.add(a);
184
+
142
185
  // Fast array comparison
143
186
  if (Array.isArray(a) && Array.isArray(b)) {
144
187
  if (a.length !== b.length) return false;
145
- return a.every((val, i) => isEqual(val, b[i]));
188
+ return a.every((val, i) => isEqual(val, b[i], visited));
146
189
  }
147
190
 
148
191
  // Fast object comparison - check keys first
@@ -154,7 +197,7 @@ export function useMobxBridge(mobxObject, options = {}) {
154
197
  if (!aKeys.every(key => bKeys.includes(key))) return false;
155
198
 
156
199
  // Check values (recursive)
157
- return aKeys.every(key => isEqual(a[key], b[key]));
200
+ return aKeys.every(key => isEqual(a[key], b[key], visited));
158
201
  };
159
202
 
160
203
  // Warning helpers to reduce duplication
@@ -162,13 +205,28 @@ export function useMobxBridge(mobxObject, options = {}) {
162
205
  const warnSetterMutation = (prop) => console.warn(`Direct mutation of setter '${prop}' is disabled`);
163
206
  const warnMethodAssignment = (prop) => console.warn(`Cannot assign to method '${prop}'`);
164
207
 
165
- // Helper to create deep proxies for nested objects and arrays
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
+ * @param {object|array} value - The nested value to wrap in a proxy
218
+ * @param {string} prop - The parent property name for error messages
219
+ * @returns {Proxy} Proxied object/array with reactive mutation handling
220
+ */
166
221
  const createDeepProxy = (value, prop) => {
167
222
  // Don't proxy built-in objects that should remain unchanged
168
223
  if (value instanceof Date || value instanceof RegExp || value instanceof Map ||
169
224
  value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) {
170
225
  return value;
171
226
  }
227
+
228
+ // Track pending updates to batch array mutations
229
+ let updatePending = false;
172
230
 
173
231
  return new Proxy(value, {
174
232
  get: (target, key) => {
@@ -189,15 +247,25 @@ export function useMobxBridge(mobxObject, options = {}) {
189
247
  }
190
248
 
191
249
  target[key] = val;
192
- // Update the Vue ref to trigger reactivity
193
- propertyRefs[prop].value = clone(propertyRefs[prop].value);
194
- // Update MobX immediately
195
- updatingFromVue.add(prop);
196
- try {
197
- mobxObject[prop] = clone(propertyRefs[prop].value);
198
- } finally {
199
- updatingFromVue.delete(prop);
250
+
251
+ // Batch updates to avoid corrupting in-progress array operations
252
+ // like shift(), unshift(), splice() which modify multiple indices synchronously
253
+ if (!updatePending) {
254
+ updatePending = true;
255
+ queueMicrotask(() => {
256
+ updatePending = false;
257
+ // Update the Vue ref to trigger reactivity
258
+ propertyRefs[prop].value = clone(propertyRefs[prop].value);
259
+ // Update MobX immediately
260
+ updatingFromVue.add(prop);
261
+ try {
262
+ mobxObject[prop] = clone(propertyRefs[prop].value);
263
+ } finally {
264
+ updatingFromVue.delete(prop);
265
+ }
266
+ });
200
267
  }
268
+
201
269
  return true;
202
270
  }
203
271
  });