mobx-vue-bridge 1.1.0 β†’ 1.3.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,10 +5,31 @@ 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.1.0] - 2025-10-01
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)
9
20
 
10
21
  ### πŸ› Bug Fixes
11
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
+
12
33
  #### Fixed `updatingFromVue` guard implementation
13
34
  - **Issue**: The `updatingFromVue` Set was declared but never populated, making it a dead guard that didn't prevent potential echo loops
14
35
  - **Impact**: Potential for echo loops in edge cases with custom objects or special equality scenarios
@@ -39,11 +60,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
39
60
 
40
61
  ### βœ… Testing
41
62
  - Added 14 new tests (7 for validation, 7 for circular references)
42
- - All 134 tests passing (120 original + 14 new)
63
+ - Updated existing tests to handle async nested mutations with `nextTick()`
64
+ - All 141 tests passing (127 original + 14 new)
43
65
  - No breaking changes to public API
44
66
  - Backward compatible with existing code
45
67
 
46
68
  ### πŸ“ Documentation
69
+ - Updated README with async behavior notes for nested mutations
70
+ - Added example showing `nextTick()` usage for immediate value access
47
71
  - Updated inline code comments for clarity
48
72
  - Added JSDoc comments for better IDE integration
49
73
 
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,6 +1,6 @@
1
1
  {
2
2
  "name": "mobx-vue-bridge",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
@@ -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,10 +6,34 @@ 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 = {}) {
15
39
  // Validate mobxObject parameter
@@ -22,6 +46,7 @@ export function useMobxBridge(mobxObject, options = {}) {
22
46
  const allowDirectMutation = safeOptions.allowDirectMutation !== undefined
23
47
  ? Boolean(safeOptions.allowDirectMutation)
24
48
  : true; // Keep the original default of true
49
+
25
50
  const vueState = reactive({});
26
51
 
27
52
  // Discover props/methods via MobX introspection (don’t rely on raw descriptors)
@@ -132,6 +157,15 @@ export function useMobxBridge(mobxObject, options = {}) {
132
157
  const updatingFromMobx = new Set();
133
158
  const updatingFromVue = new Set();
134
159
 
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
+ */
135
169
  const isEqual = (a, b, visited = new WeakSet()) => {
136
170
  if (Object.is(a, b)) return true;
137
171
 
@@ -171,13 +205,38 @@ export function useMobxBridge(mobxObject, options = {}) {
171
205
  const warnSetterMutation = (prop) => console.warn(`Direct mutation of setter '${prop}' is disabled`);
172
206
  const warnMethodAssignment = (prop) => console.warn(`Cannot assign to method '${prop}'`);
173
207
 
174
- // Helper to create deep proxies for nested objects and arrays
175
- const createDeepProxy = (value, prop) => {
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) => {
176
227
  // Don't proxy built-in objects that should remain unchanged
177
228
  if (value instanceof Date || value instanceof RegExp || value instanceof Map ||
178
229
  value instanceof Set || value instanceof WeakMap || value instanceof WeakSet) {
179
230
  return value;
180
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;
181
240
 
182
241
  return new Proxy(value, {
183
242
  get: (target, key) => {
@@ -186,7 +245,7 @@ export function useMobxBridge(mobxObject, options = {}) {
186
245
  if (result && typeof result === 'object' &&
187
246
  !(result instanceof Date || result instanceof RegExp || result instanceof Map ||
188
247
  result instanceof Set || result instanceof WeakMap || result instanceof WeakSet)) {
189
- return createDeepProxy(result, prop);
248
+ return createDeepProxy(result, prop, getRoot);
190
249
  }
191
250
  return result;
192
251
  },
@@ -194,19 +253,39 @@ export function useMobxBridge(mobxObject, options = {}) {
194
253
  // Check if direct mutation is allowed
195
254
  if (!allowDirectMutation) {
196
255
  warnDirectMutation(`${prop}.${String(key)}`);
197
- return false;
256
+ return true; // Must return true to avoid TypeError in strict mode
198
257
  }
199
258
 
259
+ // Update the target in-place (this modifies the clone in propertyRefs[prop].value)
200
260
  target[key] = val;
201
- // Update the Vue ref to trigger reactivity
202
- propertyRefs[prop].value = clone(propertyRefs[prop].value);
203
- // Update MobX immediately
204
- updatingFromVue.add(prop);
205
- try {
206
- mobxObject[prop] = clone(propertyRefs[prop].value);
207
- } finally {
208
- updatingFromVue.delete(prop);
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
+ });
209
287
  }
288
+
210
289
  return true;
211
290
  }
212
291
  });