Skip to content

Commit b6c2ee7

Browse files
committed
Refactor state.Provider to Support Intuitive Deep-Merging and Runtime Data Creation #7099
1 parent 5c90313 commit b6c2ee7

3 files changed

Lines changed: 98 additions & 21 deletions

File tree

learn/blog/v10-deep-dive-state-provider.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ const provider = myComponent.getStateProvider();
7878

7979
// This one line is all it takes to trigger a reactive update.
8080
provider.data.user.firstname = 'Max';
81+
82+
// Does not overwrite the lastname
83+
provider.setData({user: {firstname: 'Robert'}})
84+
85+
// You can update multiple properties at once. Thanks to automatic batching,
86+
// this results in only a single UI update cycle.
87+
provider.setData({user: {firstname: 'John', lastname: 'Doe'}})
88+
89+
// Alternative Syntax:
90+
provider.setData({
91+
'user.firstname': 'John',
92+
'user.lastname' : 'Doe'
93+
});
8194
```
8295

8396
There are no special setter functions to call, no reducers to write. You just change the data, and the UI updates.
@@ -98,7 +111,8 @@ The beautiful API above is powered by a sophisticated proxy created by `Neo.stat
98111
When you interact with `provider.data`, you're not touching a plain object; you're interacting with an intelligent agent
99112
that works with Neo's `EffectManager`.
100113

101-
You can see the full implementation in [state/createHierarchicalDataProxy.mjs](../../src/state/createHierarchicalDataProxy.mjs)
114+
You can see the full implementation in
115+
**[src/state/createHierarchicalDataProxy.mjs](../../src/state/createHierarchicalDataProxy.mjs)**.
102116

103117
Here’s how it works:
104118

@@ -154,7 +168,7 @@ t.it('State Provider should trigger parent effects when a leaf node changes (bub
154168
t.is(effectRunCount, 2, 'Effect should re-run after changing a leaf property');
155169
});
156170
```
157-
This behavior is made possible by the `internalSetData` method in [state/Provider.mjs](../../src/state/Provider.mjs).
171+
This behavior is made possible by the `internalSetData` method in **[state/Provider.mjs](../../src/state/Provider.mjs)**.
158172
When you set `'user.age'`, the provider doesn't just update that one value. It then "bubbles up," creating a new `user`
159173
object reference that incorporates the change: `{...oldUser, age: 31}`. This new object reference is what the reactivity
160174
system detects, ensuring that any component bound to `user` updates correctly.

src/state/Provider.mjs

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -474,24 +474,24 @@ class Provider extends Base {
474474
internalSetData(key, value, originStateProvider) {
475475
const me = this;
476476

477-
// If the value is a Neo.data.Record, treat it as an atomic value
478-
// and set it directly without further recursive processing of its properties.
479-
if (Neo.isRecord(value)) {
480-
const
481-
ownerDetails = me.getOwnerOfDataProperty(key),
482-
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
483-
484-
me.#setConfigValue(targetProvider, key, value, null);
485-
return
486-
}
487-
488477
if (Neo.isObject(key)) {
489478
Object.entries(key).forEach(([dataKey, dataValue]) => {
490479
me.internalSetData(dataKey, dataValue, originStateProvider)
491480
});
492481
return
493482
}
494483

484+
// Now 'key' is a string path.
485+
// If 'value' is a plain object, we need to drill down further.
486+
// If the value is a Neo.data.Record, treat it as an atomic value => it will not enter this block.
487+
if (Neo.typeOf(value) === 'Object') {
488+
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
489+
const fullPath = `${key}.${nestedKey}`;
490+
me.internalSetData(fullPath, nestedValue, originStateProvider);
491+
});
492+
return // We've delegated the setting to deeper paths.
493+
}
494+
495495
const
496496
ownerDetails = me.getOwnerOfDataProperty(key),
497497
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
@@ -522,7 +522,11 @@ class Provider extends Base {
522522
break // Stop if parent is not an object
523523
}
524524
} else {
525-
break // Stop if parent config does not exist
525+
// If the parent config doesn't exist, we need to create it to support bubbling.
526+
// This is crucial for creating new nested data structures at runtime.
527+
const newParentValue = {[leafKey]: latestValue};
528+
me.#setConfigValue(targetProvider, path, newParentValue);
529+
latestValue = newParentValue
526530
}
527531
}
528532
}
@@ -586,8 +590,8 @@ class Provider extends Base {
586590

587591
/**
588592
* @param {Neo.component.Base} component
589-
* @param {String} configName
590-
* @param {String} storeName
593+
* @param {String} configName
594+
* @param {String} storeName
591595
*/
592596
resolveStore(component, configName, storeName) {
593597
let store = this.getStore(storeName);
@@ -602,9 +606,9 @@ class Provider extends Base {
602606
* This method creates a new Config instance if one doesn't exist for the given path,
603607
* or updates an existing one. It also triggers binding effects and calls onDataPropertyChange.
604608
* @param {Neo.state.Provider} provider The StateProvider instance owning the config.
605-
* @param {String} path The full path of the data property (e.g., 'user.firstname').
606-
* @param {*} newValue The new value to set.
607-
* @param {*} oldVal The old value (optional, used for initial setup).
609+
* @param {String} path The full path of the data property (e.g., 'user.firstname').
610+
* @param {*} newValue The new value to set.
611+
* @param {*} [oldVal] The old value (optional, used for initial setup).
608612
* @private
609613
*/
610614
#setConfigValue(provider, path, newValue, oldVal) {
@@ -633,7 +637,7 @@ class Provider extends Base {
633637
* are run only once.
634638
*
635639
* @param {Object|String} key
636-
* @param {*} value
640+
* @param {*} value
637641
*/
638642
setData(key, value) {
639643
EffectManager.pause();
@@ -652,7 +656,7 @@ class Provider extends Base {
652656
* are run only once.
653657
*
654658
* @param {Object|String} key
655-
* @param {*} value
659+
* @param {*} value
656660
*/
657661
setDataAtSameLevel(key, value) {
658662
EffectManager.pause();

test/siesta/tests/state/ProviderNestedDataConfigs.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,65 @@ StartTest(t => {
6969
component.destroy();
7070
});
7171

72+
t.it('setData with a nested object should deep-merge and bubble reactivity', t => {
73+
const component = Neo.create(MockComponent, {
74+
stateProvider: {
75+
data: {
76+
user: {
77+
firstname: 'John',
78+
lastname : 'Doe'
79+
}
80+
}
81+
}
82+
});
83+
84+
const provider = component.getStateProvider();
85+
let effectRunCount = 0;
86+
87+
provider.createBinding(component.id, 'user', data => {
88+
effectRunCount++;
89+
return data.user;
90+
});
91+
92+
t.is(effectRunCount, 1, 'Effect ran initially');
93+
t.isDeeply(proxyToObject(component.user), { firstname: 'John', lastname: 'Doe' }, 'Initial user object is correct');
94+
95+
// ACTION: Set data with a nested object. This should MERGE, not replace.
96+
provider.setData({
97+
user: { firstname: 'Jane' }
98+
});
99+
100+
// ASSERT: The object was merged, and the old 'lastname' property is preserved.
101+
t.is(effectRunCount, 2, 'Effect re-ran after setting the branch node');
102+
103+
const updatedUser = proxyToObject(component.user);
104+
t.isDeeply(updatedUser, { firstname: 'Jane', lastname: 'Doe' }, 'User object should be deep-merged');
105+
t.is(updatedUser.lastname, 'Doe', 'The "lastname" property should be preserved after merge');
106+
107+
// For contrast, let's show the path-based "bubbling" behavior which has the same outcome.
108+
// First, reset the state.
109+
provider.setData({
110+
user: {
111+
firstname: 'John',
112+
lastname: 'Doe'
113+
}
114+
});
115+
t.is(effectRunCount, 3, 'Effect ran after resetting state');
116+
117+
// ACTION: Set a leaf node using a path string.
118+
provider.setData({
119+
'user.firstname': 'Robert'
120+
});
121+
122+
// ASSERT: The object was updated via bubbling, preserving the 'lastname' property.
123+
t.is(effectRunCount, 4, 'Effect re-ran after setting a leaf node via path');
124+
const mergedUser = proxyToObject(component.user);
125+
t.isDeeply(mergedUser, { firstname: 'Robert', lastname: 'Doe' }, 'Path-based set should merge/preserve other properties');
126+
t.is(mergedUser.lastname, 'Doe', 'The "lastname" property should be preserved');
127+
128+
component.destroy();
129+
});
130+
72131
t.it('Formulas should react to leaf node changes via bubbling', t => {
73132
let effectRunCount = 0;
74133

0 commit comments

Comments
 (0)