Skip to content

Commit 256d933

Browse files
committed
Feature Request: Enhance State Provider with Non-Leaf and Batched Reactivity #7082
1 parent 2253053 commit 256d933

3 files changed

Lines changed: 84 additions & 69 deletions

File tree

src/state/Provider.mjs

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Base from '../core/Base.mjs';
22
import ClassSystemUtil from '../util/ClassSystem.mjs';
33
import Config from '../core/Config.mjs';
44
import Effect from '../core/Effect.mjs';
5+
import EffectBatchManager from '../core/EffectBatchManager.mjs';
56
import Observable from '../core/Observable.mjs';
67
import {createHierarchicalDataProxy} from './createHierarchicalDataProxy.mjs';
78
import {isDescriptor} from '../core/ConfigSymbols.mjs';
@@ -449,30 +450,33 @@ class Provider extends Base {
449450
}
450451
}
451452
}
452-
return Array.from(keys);
453+
454+
return Array.from(keys)
453455
}
454456

455457
/**
456-
* Internal method to avoid code redundancy.
457-
* Use setData() or setDataAtSameLevel() instead.
458+
* This is the core method for setting data, providing a single entry point for all data modifications.
459+
* It handles multiple scenarios:
460+
* 1. **Object-based updates:** If `key` is an object, it recursively calls itself for each key-value pair.
461+
* 2. **Data Records:** If `value` is a `Neo.data.Record`, it is treated as an atomic value and set directly.
462+
* 3. **Bubbling Reactivity:** For a given key (e.g., 'user.name'), it sets the leaf value and then "bubbles up"
463+
* the change, creating new parent objects (e.g., 'user') to ensure that effects depending on any part
464+
* of the path are triggered.
458465
*
459-
* This method handles setting data properties, including nested paths and Neo.data.Record instances.
460-
* It determines the owning StateProvider in the hierarchy and delegates to #setConfigValue.
466+
* All updates are batched by the public `setData` methods to ensure effects run only once.
467+
* Use `setData()` or `setDataAtSameLevel()` instead of calling this method directly.
461468
*
462-
* Passing an originStateProvider param will try to set each key on the closest property match
463-
* inside the parent stateProvider chain => setData()
464-
* Not passing it will set all values on the stateProvider where the method gets called => setDataAtSameLevel()
465-
* @param {Object|String} key
466-
* @param {*} value
467-
* @param {Neo.state.Provider} [originStateProvider]
469+
* @param {Object|String} key The property to set, or an object of key-value pairs.
470+
* @param {*} value The new value.
471+
* @param {Neo.state.Provider} [originStateProvider] The provider to start the search from for hierarchical updates.
468472
* @protected
469473
*/
470474
internalSetData(key, value, originStateProvider) {
471475
const me = this;
472476

473477
// If the value is a Neo.data.Record, treat it as an atomic value
474478
// and set it directly without further recursive processing of its properties.
475-
if (Neo.isObject(value) && value.isRecord) {
479+
if (Neo.isRecord(value)) {
476480
const
477481
ownerDetails = me.getOwnerOfDataProperty(key),
478482
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
@@ -481,42 +485,40 @@ class Provider extends Base {
481485
return
482486
}
483487

484-
// If the key is an object, iterate over its entries and recursively call internalSetData.
485-
// This handles setting multiple properties at once (e.g., setData({prop1: val1, prop2: val2})).
486488
if (Neo.isObject(key)) {
487489
Object.entries(key).forEach(([dataKey, dataValue]) => {
488490
me.internalSetData(dataKey, dataValue, originStateProvider)
489491
});
490492
return
491493
}
492494

493-
// Handle single key/value pairs, including nested paths (e.g., 'user.firstName').
494495
const
495496
ownerDetails = me.getOwnerOfDataProperty(key),
496-
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me),
497-
pathParts = key.split('.');
498-
499-
let currentPath = '',
500-
currentConfig = null,
501-
currentProvider = targetProvider;
502-
503-
for (let i = 0; i < pathParts.length; i++) {
504-
const part = pathParts[i];
505-
currentPath = currentPath ? `${currentPath}.${part}` : part;
506-
currentConfig = currentProvider.getDataConfig(currentPath);
507-
508-
if (i === pathParts.length - 1) { // Last part of the path
509-
// Set the value for the final property in the path.
510-
me.#setConfigValue(currentProvider, currentPath, value, null)
511-
} else { // Intermediate part of the path
512-
// Ensure intermediate paths exist as objects. If not, create them.
513-
// If an intermediate path exists but is not an object, overwrite it with an empty object.
514-
if (!currentConfig) {
515-
currentConfig = new Config({}); // Create an empty object config
516-
currentProvider.#dataConfigs[currentPath] = currentConfig
517-
} else if (!Neo.isObject(currentConfig.get())) {
518-
currentConfig.set({})
497+
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
498+
499+
me.#setConfigValue(targetProvider, key, value, null);
500+
501+
// Bubble up the change to parent configs to trigger their effects
502+
let path = key,
503+
latestValue = value;
504+
505+
while (path.includes('.')) {
506+
const leafKey = path.split('.').pop();
507+
path = path.substring(0, path.lastIndexOf('.'));
508+
509+
const parentConfig = targetProvider.getDataConfig(path);
510+
511+
if (parentConfig) {
512+
const oldParentValue = parentConfig.get();
513+
if (Neo.isObject(oldParentValue)) {
514+
const newParentValue = { ...oldParentValue, [leafKey]: latestValue };
515+
parentConfig.set(newParentValue);
516+
latestValue = newParentValue;
517+
} else {
518+
break // Stop if parent is not an object
519519
}
520+
} else {
521+
break // Stop if parent config does not exist
520522
}
521523
}
522524
}
@@ -622,21 +624,33 @@ class Provider extends Base {
622624
/**
623625
* The method will assign all values to the closest stateProvider where it finds an existing key.
624626
* In case no match is found inside the parent chain, a new data property will get generated.
627+
*
628+
* All updates within a single call are batched to ensure that reactive effects (bindings and formulas)
629+
* are run only once.
630+
*
625631
* @param {Object|String} key
626632
* @param {*} value
627633
*/
628634
setData(key, value) {
629-
this.internalSetData(key, value, this)
635+
EffectBatchManager.startBatch();
636+
this.internalSetData(key, value, this);
637+
EffectBatchManager.endBatch()
630638
}
631639

632640
/**
633641
* Use this method instead of setData() in case you want to enforce
634642
* setting all keys on this instance instead of looking for matches inside parent stateProviders.
643+
*
644+
* All updates within a single call are batched to ensure that reactive effects (bindings and formulas)
645+
* are run only once.
646+
*
635647
* @param {Object|String} key
636648
* @param {*} value
637649
*/
638650
setDataAtSameLevel(key, value) {
639-
this.internalSetData(key, value)
651+
EffectBatchManager.startBatch();
652+
this.internalSetData(key, value);
653+
EffectBatchManager.endBatch()
640654
}
641655
}
642656

src/state/createHierarchicalDataProxy.mjs

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ function createNestedProxy(rootProvider, path) {
2222
// Handle internal properties that might be set directly on the proxy's target
2323
// or are expected by the environment (like Siesta's __REFADR__).
2424
if (typeof property === 'symbol' || property === '__REFADR__' || property === 'inspect' || property === 'then') {
25-
return Reflect.get(currentTarget, property);
25+
return Reflect.get(currentTarget, property)
2626
}
2727

2828
// Only allow string or number properties to proceed as data paths.
@@ -35,13 +35,13 @@ function createNestedProxy(rootProvider, path) {
3535
return new Proxy({}, {
3636
get(target, storeName) {
3737
if (typeof storeName === 'symbol' || storeName === '__REFADR__') {
38-
return Reflect.get(target, storeName);
38+
return Reflect.get(target, storeName)
3939
}
4040
// Delegate to the StateProvider's getStore method for hierarchical resolution
4141
// Accessing store.count later will register the dependency via the Config system
42-
return rootProvider.getStore(storeName);
42+
return rootProvider.getStore(storeName)
4343
}
44-
});
44+
})
4545
}
4646

4747
const fullPath = path ? `${path}.${property}` : property;
@@ -55,17 +55,15 @@ function createNestedProxy(rootProvider, path) {
5555
config = owner.getDataConfig(propertyName);
5656

5757
if (config) {
58-
const activeEffect = EffectManager.getActiveEffect();
59-
if (activeEffect) {
60-
activeEffect.addDependency(config);
61-
}
58+
EffectManager.getActiveEffect()?.addDependency(config);
6259

6360
const value = config.get();
6461
// If the value is an object, return a new proxy for it to ensure nested accesses are also proxied.
65-
if (Neo.typeOf(value) === 'Object') {
62+
if (Neo.isObject(value)) {
6663
return createNestedProxy(rootProvider, fullPath)
6764
}
68-
return value;
65+
66+
return value
6967
}
7068
}
7169

@@ -76,52 +74,54 @@ function createNestedProxy(rootProvider, path) {
7674
return createNestedProxy(rootProvider, fullPath)
7775
}
7876

79-
// 3. If it's neither a data property nor a path to one, it doesn't exist in the state.
80-
return null
77+
// 3. If it's neither a data property nor a path to one, it doesn't exist.
78+
// Returning undefined ensures that chained accesses (e.g., data.nonexistent.property) fail gracefully.
8179
},
8280

8381
set(currentTarget, property, value) {
8482
// Allow internal properties (like Symbols or specific strings) to be set directly on the target.
8583
if (typeof property === 'symbol' || property === '__REFADR__') {
86-
return Reflect.set(currentTarget, property, value);
84+
return Reflect.set(currentTarget, property, value)
8785
}
8886

89-
const fullPath = path ? `${path}.${property}` : property;
90-
const ownerDetails = rootProvider.getOwnerOfDataProperty(fullPath);
91-
87+
const
88+
fullPath = path ? `${path}.${property}` : property,
89+
ownerDetails = rootProvider.getOwnerOfDataProperty(fullPath);
9290
let targetProvider;
91+
9392
if (ownerDetails) {
94-
targetProvider = ownerDetails.owner;
93+
targetProvider = ownerDetails.owner
9594
} else {
9695
// If no owner is found, set it on the rootProvider (the one that created this proxy)
97-
targetProvider = rootProvider;
96+
targetProvider = rootProvider
9897
}
9998

10099
targetProvider.setData(fullPath, value);
101-
return true; // Indicate that the assignment was successful
100+
return true // Indicate that the assignment was successful
102101
},
103102

104103
ownKeys(currentTarget) {
105-
return rootProvider.getTopLevelDataKeys(path);
104+
return rootProvider.getTopLevelDataKeys(path)
106105
},
107106

108107
getOwnPropertyDescriptor(currentTarget, property) {
109-
const fullPath = path ? `${path}.${property}` : property;
110-
const ownerDetails = rootProvider.getOwnerOfDataProperty(fullPath);
108+
const
109+
fullPath = path ? `${path}.${property}` : property,
110+
ownerDetails = rootProvider.getOwnerOfDataProperty(fullPath);
111111

112112
if (ownerDetails) {
113113
const config = ownerDetails.owner.getDataConfig(ownerDetails.propertyName);
114+
114115
if (config) {
115116
const value = config.get();
116117
return {
117-
value: Neo.isObject(value) ? createNestedProxy(rootProvider, fullPath) : value,
118-
writable: true,
119-
enumerable: true,
120-
configurable: true,
121-
};
118+
value : Neo.isObject(value) ? createNestedProxy(rootProvider, fullPath) : value,
119+
writable : true,
120+
enumerable : true,
121+
configurable: true
122+
}
122123
}
123124
}
124-
return undefined; // Property not found
125125
}
126126
})
127127
}

test/siesta/siesta.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ project.plan(
4848
group: 'state',
4949
items: [
5050
'tests/state/createHierarchicalDataProxy.mjs',
51-
'tests/state/Provider.mjs'
51+
'tests/state/Provider.mjs',
52+
'tests/state/ProviderNestedDataConfigs.mjs'
5253
]
5354
},
5455
'tests/CollectionBase.mjs',

0 commit comments

Comments
 (0)