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 +26 -2
- package/README.md +71 -0
- package/package.json +10 -2
- package/src/mobxVueBridge.js +94 -15
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.
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
"
|
|
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,10 +6,34 @@ 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 = {}) {
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
});
|