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 +73 -0
- package/README.md +71 -0
- package/package.json +11 -3
- package/src/mobxVueBridge.js +83 -15
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
|
|
4
|
-
"description": "A bridge
|
|
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
|
-
"
|
|
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"
|
package/src/mobxVueBridge.js
CHANGED
|
@@ -6,17 +6,47 @@ import clone from 'clone';
|
|
|
6
6
|
/**
|
|
7
7
|
* π MobX-Vue Bridge
|
|
8
8
|
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
});
|