neo.mjs 10.0.0-beta.4 → 10.0.0-beta.5

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.
Files changed (46) hide show
  1. package/.github/RELEASE_NOTES/v10.0.0-beta.4.md +2 -2
  2. package/ServiceWorker.mjs +2 -2
  3. package/apps/portal/index.html +1 -1
  4. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  5. package/examples/button/effect/MainContainer.mjs +207 -0
  6. package/examples/button/effect/app.mjs +6 -0
  7. package/examples/button/effect/index.html +11 -0
  8. package/examples/button/effect/neo-config.json +6 -0
  9. package/learn/guides/datahandling/StateProviders.md +1 -0
  10. package/learn/guides/fundamentals/DeclarativeVDOMWithEffects.md +166 -0
  11. package/learn/tree.json +1 -0
  12. package/package.json +2 -2
  13. package/src/DefaultConfig.mjs +2 -2
  14. package/src/Neo.mjs +226 -78
  15. package/src/button/Effect.mjs +435 -0
  16. package/src/collection/Base.mjs +7 -2
  17. package/src/component/Base.mjs +67 -46
  18. package/src/container/Base.mjs +28 -24
  19. package/src/core/Base.mjs +138 -19
  20. package/src/core/Config.mjs +123 -32
  21. package/src/core/Effect.mjs +127 -0
  22. package/src/core/EffectBatchManager.mjs +68 -0
  23. package/src/core/EffectManager.mjs +38 -0
  24. package/src/grid/Container.mjs +8 -4
  25. package/src/grid/column/Component.mjs +1 -1
  26. package/src/state/Provider.mjs +343 -452
  27. package/src/state/createHierarchicalDataProxy.mjs +124 -0
  28. package/src/tab/header/EffectButton.mjs +75 -0
  29. package/src/vdom/Helper.mjs +9 -10
  30. package/src/vdom/VNode.mjs +1 -1
  31. package/src/worker/App.mjs +0 -5
  32. package/test/siesta/siesta.js +32 -0
  33. package/test/siesta/tests/CollectionBase.mjs +10 -10
  34. package/test/siesta/tests/VdomHelper.mjs +22 -59
  35. package/test/siesta/tests/config/AfterSetConfig.mjs +100 -0
  36. package/test/siesta/tests/{ReactiveConfigs.mjs → config/Basic.mjs} +58 -21
  37. package/test/siesta/tests/config/CircularDependencies.mjs +166 -0
  38. package/test/siesta/tests/config/CustomFunctions.mjs +69 -0
  39. package/test/siesta/tests/config/Hierarchy.mjs +94 -0
  40. package/test/siesta/tests/config/MemoryLeak.mjs +92 -0
  41. package/test/siesta/tests/config/MultiLevelHierarchy.mjs +85 -0
  42. package/test/siesta/tests/core/Effect.mjs +131 -0
  43. package/test/siesta/tests/core/EffectBatching.mjs +322 -0
  44. package/test/siesta/tests/neo/MixinStaticConfig.mjs +138 -0
  45. package/test/siesta/tests/state/Provider.mjs +537 -0
  46. package/test/siesta/tests/state/createHierarchicalDataProxy.mjs +217 -0
@@ -1,11 +1,12 @@
1
- import Base from '../core/Base.mjs';
2
- import ClassSystemUtil from '../util/ClassSystem.mjs';
3
- import NeoArray from '../util/Array.mjs';
4
- import Observable from '../core/Observable.mjs';
1
+ import Base from '../core/Base.mjs';
2
+ import ClassSystemUtil from '../util/ClassSystem.mjs';
3
+ import Config from '../core/Config.mjs';
4
+ import Effect from '../core/Effect.mjs';
5
+ import Observable from '../core/Observable.mjs';
6
+ import {createHierarchicalDataProxy} from './createHierarchicalDataProxy.mjs';
7
+ import {isDescriptor} from '../core/ConfigSymbols.mjs';
5
8
 
6
- const dataVariableRegex = /data((?!(\.[a-z_]\w*\(\)))\.[a-z_]\w*)+/gi,
7
- twoWayBindingSymbol = Symbol.for('twoWayBinding'),
8
- variableNameRegex = /^\w*/;
9
+ const twoWayBindingSymbol = Symbol.for('twoWayBinding');
9
10
 
10
11
  /**
11
12
  * An optional component state provider for adding bindings to configs
@@ -31,38 +32,52 @@ class Provider extends Base {
31
32
  * @protected
32
33
  */
33
34
  ntype: 'state-provider',
34
- /**
35
- * @member {Object|null} bindings_=null
36
- * @protected
37
- */
38
- bindings_: null,
39
35
  /**
40
36
  * @member {Neo.component.Base|null} component=null
41
37
  * @protected
42
38
  */
43
39
  component: null,
44
40
  /**
41
+ /**
42
+ * The core data object managed by this StateProvider.
43
+ * This object holds the reactive state that can be accessed and modified
44
+ * by components and formulas within the provider's hierarchy.
45
+ * Changes to properties within this data object will trigger reactivity.
46
+ * When new data is assigned, it will be deeply merged with existing data.
45
47
  * @member {Object|null} data_=null
48
+ * @example
49
+ * data: {
50
+ * user: {
51
+ * firstName: 'John',
52
+ * lastName : 'Doe'
53
+ * },
54
+ * settings: {
55
+ * theme: 'dark'
56
+ * }
57
+ * }
46
58
  */
47
- data_: null,
59
+ data_: {
60
+ [isDescriptor]: true,
61
+ merge : 'deep',
62
+ value : {}
63
+ },
48
64
  /**
65
+ * Defines computed properties based on other data properties within the StateProvider hierarchy.
66
+ * Each formula is a function that receives a `data` argument, which is a hierarchical proxy
67
+ * allowing access to data from the current provider and all its parent providers.
68
+ * Changes to dependencies (accessed via `data.propertyName`) will automatically re-run the formula.
49
69
  * @member {Object|null} formulas_=null
50
- *
51
70
  * @example
52
71
  * data: {
53
- * a: 1,
54
- * b: 2
72
+ * a : 1,
73
+ * b : 2,
74
+ * total: 50
55
75
  * }
56
76
  * formulas: {
57
- * aPlusB: {
58
- * bind: {
59
- * foo: 'a',
60
- * bar: 'b'
61
- * },
62
- * get(data) {
63
- * return data.foo + data.bar
64
- * }
65
- * }
77
+ * aPlusB : (data) => data.a + data.b,
78
+ * aTimesB: (data) => data.a * data.b,
79
+ * // Accessing parent data (assuming a parent provider has a 'taxRate' property)
80
+ * totalWithTax: (data) => data.total * (1 + data.taxRate)
66
81
  * }
67
82
  */
68
83
  formulas_: null,
@@ -71,60 +86,101 @@ class Provider extends Base {
71
86
  */
72
87
  parent_: null,
73
88
  /**
89
+ /**
90
+ * A collection of Neo.data.Store instances managed by this StateProvider.
91
+ * Stores are defined as config objects with a `module` property pointing
92
+ * to the store class, which will then be instantiated by the framework.
74
93
  * @member {Object|null} stores_=null
94
+ * @example
95
+ * stores: {
96
+ * myUsers: {
97
+ * module: Neo.data.Store,
98
+ * model : 'MyApp.model.User',
99
+ * data : [{id: 1, name: 'John'}, {id: 2, name: 'Doe'}]
100
+ * },
101
+ * myCustomStore1: MyCustomStoreClass,
102
+ * myCustomStore2: {
103
+ * module : MyCustomStoreClass,
104
+ * autoLoad: true
105
+ * }
106
+ * }
75
107
  */
76
108
  stores_: null
77
109
  }
78
110
 
79
111
  /**
80
- * @param {Object} config
112
+ * @member {Map} #bindingEffects=new Map()
113
+ * @private
81
114
  */
82
- construct(config) {
83
- Neo.currentWorker.isUsingStateProviders = true;
84
- super.construct(config);
85
- this.bindings = {}
86
- }
87
-
115
+ #bindingEffects = new Map()
88
116
  /**
89
- * Adds a given key/value combination on this stateProvider level.
90
- * The method is used by setData() & setDataAtSameLevel()
91
- * in case the data property does not exist yet.
92
- * @param {String} key
93
- * @param {*} value
117
+ * @member {Object} #dataConfigs={}
94
118
  * @private
95
119
  */
96
- addDataProperty(key, value) {
97
- let me = this,
98
- data, scope;
99
-
100
- Neo.ns(key, true, me.data);
101
-
102
- data = me.getDataScope(key);
103
- scope = data.scope;
104
-
105
- scope[data.key] = value;
120
+ #dataConfigs = {}
121
+ /**
122
+ * @member {Map} #formulaEffects=new Map()
123
+ * @private
124
+ */
125
+ #formulaEffects = new Map()
106
126
 
107
- me.createDataProperties(me.data, 'data')
127
+ /**
128
+ * @param {Object} config
129
+ */
130
+ construct(config) {
131
+ Neo.isUsingStateProviders = true;
132
+ super.construct(config)
108
133
  }
109
134
 
110
135
  /**
111
- * Triggered after the data config got changed
136
+ * Triggered after the data config got changed.
137
+ * This method initializes the internal #dataConfigs map, converting each
138
+ * plain data property into a reactive Neo.core.Config instance.
112
139
  * @param {Object|null} value
113
140
  * @param {Object|null} oldValue
114
141
  * @protected
115
142
  */
116
143
  afterSetData(value, oldValue) {
117
- value && this.createDataProperties(value, 'data')
144
+ value && this.processDataObject(value)
118
145
  }
119
146
 
120
147
  /**
121
- * Triggered after the formulas config got changed
122
- * @param {Object|null} value
123
- * @param {Object|null} oldValue
148
+ * Triggered after the formulas config got changed.
149
+ * This method sets up reactive effects for each defined formula.
150
+ * Each formula function receives the hierarchical data proxy, allowing implicit dependency tracking.
151
+ * @param {Object|null} value The new formulas configuration.
152
+ * @param {Object|null} oldValue The old formulas configuration.
124
153
  * @protected
125
154
  */
126
155
  afterSetFormulas(value, oldValue) {
127
- value && this.resolveFormulas(null)
156
+ const me = this;
157
+
158
+ // Destroy old formula effects to prevent memory leaks and stale calculations.
159
+ me.#formulaEffects.forEach(effect => effect.destroy());
160
+ me.#formulaEffects.clear();
161
+
162
+ if (value) {
163
+ Object.entries(value).forEach(([formulaKey, formulaFn]) => {
164
+ // Create a new Effect for each formula. The Effect's fn will re-run whenever its dependencies change.
165
+ const effect = new Effect({
166
+ fn: () => {
167
+ const
168
+ hierarchicalData = me.getHierarchyData(), // Get the reactive data proxy
169
+ result = formulaFn(hierarchicalData); // Execute the formula with the data
170
+
171
+ // Assign the result back to the state provider's data.
172
+ // This makes the formula's output available as a data property.
173
+ if (isNaN(result)) {
174
+ me.setData(formulaKey, null)
175
+ } else {
176
+ me.setData(formulaKey, result)
177
+ }
178
+ }
179
+ });
180
+
181
+ me.#formulaEffects.set(formulaKey, effect)
182
+ })
183
+ }
128
184
  }
129
185
 
130
186
  /**
@@ -133,17 +189,7 @@ class Provider extends Base {
133
189
  * @protected
134
190
  */
135
191
  beforeGetData(value) {
136
- return value || {}
137
- }
138
-
139
- /**
140
- * Triggered before the parent config gets changed
141
- * @param {Neo.state.Provider|null} value
142
- * @param {Neo.state.Provider|null} oldValue
143
- * @protected
144
- */
145
- beforeSetParent(value, oldValue) {
146
- return value ? value : this.getParent()
192
+ return this.getHierarchyData()
147
193
  }
148
194
 
149
195
  /**
@@ -171,159 +217,93 @@ class Provider extends Base {
171
217
  }
172
218
 
173
219
  /**
174
- * @param {Function} formatter
175
- * @param {Object} data=null optionally pass this.getHierarchyData() for performance reasons
176
- * @returns {String}
177
- */
178
- callFormatter(formatter, data=null) {
179
- if (!data) {
180
- data = this.getHierarchyData()
181
- }
182
-
183
- return formatter.call(this, data)
184
- }
185
-
186
- /**
187
- * Registers a new binding in case a matching data property does exist.
188
- * Otherwise, it will use the closest stateProvider with a match.
220
+ * Creates a new binding for a component's config to a data property.
221
+ * This now uses the Effect-based reactivity system.
189
222
  * @param {String} componentId
190
- * @param {String} key
191
- * @param {String} value
192
- * @param {String} formatter
193
- */
194
- createBinding(componentId, key, value, formatter) {
195
- let me = this,
196
- data = me.getDataScope(key),
197
- scope = data.scope,
198
- keyLeaf = data.key,
199
- bindingScope, parentStateProvider;
200
-
201
- if (scope?.hasOwnProperty(keyLeaf)) {
202
- bindingScope = Neo.ns(`${key}.${componentId}`, true, me.bindings);
203
- bindingScope[value] = formatter
204
- } else {
205
- parentStateProvider = me.getParent();
206
-
207
- if (parentStateProvider) {
208
- parentStateProvider.createBinding(componentId, key, value, formatter)
209
- } else {
210
- console.error('No state.Provider found with the specified data property', componentId, keyLeaf, value)
223
+ * @param {String} configKey The component config to bind (e.g., 'text').
224
+ * @param {String|Function} formatter The function that computes the value.
225
+ */
226
+ createBinding(componentId, configKey, key, isTwoWay) {
227
+ const
228
+ me = this,
229
+ effect = new Effect({
230
+ fn: () => {
231
+ const component = Neo.get(componentId);
232
+
233
+ if (component && !component.isDestroyed) {
234
+ const
235
+ hierarchicalData = me.getHierarchyData(),
236
+ newValue = Neo.isFunction(key) ? key.call(me, hierarchicalData) : hierarchicalData[key];
237
+
238
+ component._skipTwoWayPush = configKey;
239
+ component[configKey] = newValue;
240
+ delete component._skipTwoWayPush
241
+ }
211
242
  }
212
- }
213
- }
243
+ });
214
244
 
215
- /**
216
- * Registers a new binding in case a matching data property does exist.
217
- * Otherwise, it will use the closest stateProvider with a match.
218
- * @param {String} componentId
219
- * @param {String} formatter
220
- * @param {String} value
221
- * @returns {String[]}
222
- */
223
- createBindingByFormatter(componentId, formatter, value) {
224
- let me = this,
225
- formatterVars = me.getFormatterVariables(formatter);
245
+ me.#bindingEffects.set(componentId, effect);
226
246
 
227
- formatterVars.forEach(key => {
228
- me.createBinding(componentId, key, value, formatter)
247
+ // The effect observes the component's destruction to clean itself up.
248
+ me.observeConfig(componentId, 'isDestroying', (value) => {
249
+ if (value) {
250
+ effect.destroy();
251
+ me.#bindingEffects.delete(componentId)
252
+ }
229
253
  });
230
254
 
231
- return formatterVars
255
+ // The effect is returned to be managed by the component.
256
+ return effect
232
257
  }
233
258
 
234
259
  /**
235
- * @param {Neo.component.Base} component
260
+ * Processes a component's `bind` configuration to create reactive bindings.
261
+ * It differentiates between store bindings and data bindings, and sets up two-way binding if specified.
262
+ * @param {Neo.component.Base} component The component instance whose bindings are to be created.
236
263
  */
237
264
  createBindings(component) {
238
- Object.entries(component.bind).forEach(([key, value]) => {
239
- let twoWayBinding = false,
240
- formatterVars;
241
-
242
- if (Neo.isObject(value)) {
243
- twoWayBinding = true;
244
- value = value.value
245
- }
265
+ let hasTwoWayBinding = false;
246
266
 
247
- if (!this.isStoreValue(value)) {
248
- formatterVars = this.createBindingByFormatter(component.id, value, key);
267
+ Object.entries(component.bind || {}).forEach(([configKey, value]) => {
268
+ let key = value;
249
269
 
250
- if (twoWayBinding) {
251
- component.bind[key].key = formatterVars[0];
252
- component[twoWayBindingSymbol] = true;
270
+ // If the binding value is an object, it might contain `twoWay` or a specific `key`.
271
+ if (Neo.isObject(value)) {
272
+ if (value.twoWay) {
273
+ hasTwoWayBinding = true
253
274
  }
275
+ key = value.key
254
276
  }
255
- })
256
- }
257
-
258
- /**
259
- * @param {Object} config
260
- * @param {String} path
261
- */
262
- createDataProperties(config, path) {
263
- let me = this,
264
- root = Neo.ns(path, false, me),
265
- descriptor, keyValue, newPath;
266
-
267
- Object.entries(config).forEach(([key, value]) => {
268
- if (!key.startsWith('_')) {
269
- descriptor = Object.getOwnPropertyDescriptor(root, key);
270
- newPath = `${path}.${key}`
271
-
272
- if (!(typeof descriptor === 'object' && typeof descriptor.set === 'function')) {
273
- keyValue = config[key];
274
- me.createDataProperty(key, newPath, root);
275
- root[key] = keyValue
276
- }
277
277
 
278
- if (Neo.isObject(value)) {
279
- me.createDataProperties(config[key], newPath)
280
- }
278
+ // Determine if it's a store binding or a data binding.
279
+ if (this.isStoreValue(key)) {
280
+ // For store bindings, resolve the store and assign it to the component config.
281
+ this.resolveStore(component, configKey, key.substring(7)) // remove the "stores." prefix
282
+ } else {
283
+ // For data bindings, create an Effect to keep the component config in sync with the data.
284
+ this.createBinding(component.id, configKey, key, value.twoWay)
281
285
  }
282
- })
286
+ });
287
+
288
+ // Mark the component if it has any two-way bindings, for internal tracking.
289
+ if (hasTwoWayBinding) {
290
+ component[twoWayBindingSymbol] = true
291
+ }
283
292
  }
284
293
 
285
294
  /**
286
- * @param {String} key
287
- * @param {String} path
288
- * @param {Object} root=this.data
295
+ * Destroys the state provider and cleans up all associated effects.
289
296
  */
290
- createDataProperty(key, path, root=this.data) {
291
- let me = this;
292
-
293
- if (path?.startsWith('data.')) {
294
- path = path.substring(5)
295
- }
297
+ destroy() {
298
+ const me = this;
296
299
 
297
- Object.defineProperty(root, key, {
298
- get() {
299
- let value = root['_' + key];
300
+ me.#formulaEffects.forEach(effect => effect.destroy());
301
+ me.#formulaEffects.clear();
300
302
 
301
- if (Neo.typeOf(value) === 'Date') {
302
- value = new Date(value.valueOf())
303
- }
303
+ me.#bindingEffects.forEach(effect => effect.destroy());
304
+ me.#bindingEffects.clear();
304
305
 
305
- return value
306
- },
307
-
308
- set(value) {
309
- let _key = `_${key}`,
310
- oldValue = root[_key];
311
-
312
- if (!root[_key]) {
313
- Object.defineProperty(root, _key, {
314
- enumerable: false,
315
- value,
316
- writable : true
317
- })
318
- } else {
319
- root[_key] = value
320
- }
321
-
322
- if (!Neo.isEqual(value, oldValue)) {
323
- me.onDataPropertyChange(path ? path : key, value, oldValue)
324
- }
325
- }
326
- })
306
+ super.destroy()
327
307
  }
328
308
 
329
309
  /**
@@ -338,133 +318,52 @@ class Provider extends Base {
338
318
  /**
339
319
  * Access the closest data property inside the parent chain.
340
320
  * @param {String} key
341
- * @param {Neo.state.Provider} originStateProvider=this for internal usage only
342
321
  * @returns {*} value
343
322
  */
344
- getData(key, originStateProvider=this) {
345
- let me = this,
346
- data = me.getDataScope(key),
347
- {scope} = data,
348
- keyLeaf = data.key,
349
- parentStateProvider;
323
+ getData(key) {
324
+ const ownerDetails = this.getOwnerOfDataProperty(key);
350
325
 
351
- if (scope?.hasOwnProperty(keyLeaf)) {
352
- return scope[keyLeaf]
326
+ if (ownerDetails) {
327
+ return ownerDetails.owner.getDataConfig(ownerDetails.propertyName).get()
353
328
  }
354
-
355
- parentStateProvider = me.getParent();
356
-
357
- if (!parentStateProvider) {
358
- console.error(`data property '${key}' does not exist.`, originStateProvider)
359
- }
360
-
361
- return parentStateProvider.getData(key, originStateProvider)
362
329
  }
363
330
 
364
331
  /**
365
- * Helper method to get the scope for a nested data property via Neo.ns() if needed.
366
- *
367
- * Example: passing the value 'foo.bar.baz' will return the bar object as the scope
368
- * and 'baz' as the key.
369
- * @param key
370
- * @returns {Object}
332
+ * Retrieves the underlying core.Config instance for a given data property path.
333
+ * @param {String} path The full path of the data property (e.g., 'user.firstname').
334
+ * @returns {Neo.core.Config|null}
371
335
  */
372
- getDataScope(key) {
373
- let me = this,
374
- keyLeaf = key,
375
- {data} = me;
376
-
377
- if (key.includes('.')) {
378
- key = key.split('.');
379
- keyLeaf = key.pop();
380
- data = Neo.ns(key.join('.'), false, data)
381
- }
382
-
383
- return {
384
- key : keyLeaf,
385
- scope: data
386
- }
336
+ getDataConfig(path) {
337
+ return this.#dataConfigs[path] || null
387
338
  }
388
339
 
389
340
  /**
390
- * Extracts data variables from a given formatter string
391
- * @param {String} value
341
+ * Returns the merged, hierarchical data object as a reactive Proxy.
342
+ * @returns {Proxy}
392
343
  */
393
- getFormatterVariables(value) {
394
- let {environment} = Neo.config;
395
-
396
- if (Neo.isFunction(value)) {
397
- value = value.toString()
398
- }
399
-
400
- if (environment === 'dist/esm' || environment === 'dist/production') {
401
- // See: https://github.com/neomjs/neo/issues/2371
402
- // Inside dist/esm & dist/prod the formatter:
403
- // data => DateUtil.convertToyyyymmdd(data.currentDate)
404
- // will get minified to:
405
- // e=>s.Z.convertToyyyymmdd(e.currentDate)
406
- // The new strategy: find the first variable name => "e"
407
- // Replace it with "data":
408
- // data=>s.Z.convertToyyyymmdd(data.currentDate)
409
- // From there we can use the dev mode regex again.
410
-
411
- let dataName = value.match(variableNameRegex)[0],
412
- variableRegExp = new RegExp(`(^|[^\\w.])(${dataName})(?!\\w)`, 'g');
413
-
414
- value = value.replace(variableRegExp, '$1data')
415
- }
416
-
417
- let dataVars = value.match(dataVariableRegex) || [],
418
- result = [];
419
-
420
- dataVars.forEach(variable => {
421
- // remove the "data." at the start
422
- variable = variable.substr(5);
423
- NeoArray.add(result, variable)
424
- });
425
-
426
- result.sort();
427
-
428
- return result
344
+ getHierarchyData() {
345
+ return createHierarchicalDataProxy(this)
429
346
  }
430
347
 
431
348
  /**
432
- * Returns the merged data
433
- * @param {Object} data=this.getPlainData()
434
- * @returns {Object} data
349
+ * Finds the state.Provider instance that owns a specific data property.
350
+ * @param {String} path The full path of the data property.
351
+ * @returns {{owner: Neo.state.Provider, propertyName: String}|null}
435
352
  */
436
- getHierarchyData(data=this.getPlainData()) {
437
- let me = this,
438
- parent = me.getParent();
353
+ getOwnerOfDataProperty(path) {
354
+ let me = this;
439
355
 
440
- if (parent) {
441
- return {
442
- ...parent.getHierarchyData(data),
443
- ...me.getPlainData()
444
- }
356
+ if (me.#dataConfigs[path]) {
357
+ return {owner: me, propertyName: path}
445
358
  }
446
359
 
447
- return me.getPlainData()
448
- }
449
-
450
- /**
451
- * Returns a plain version of this.data.
452
- * This excludes the property getters & setters.
453
- * @param {Object} data=this.data
454
- * @returns {Object}
455
- */
456
- getPlainData(data=this.data) {
457
- let plainData = {};
458
-
459
- Object.entries(data).forEach(([key, value]) => {
460
- if (Neo.typeOf(value) === 'Object') {
461
- plainData[key] = this.getPlainData(value)
462
- } else {
463
- plainData[key] = value
464
- }
465
- });
360
+ // Check for parent ownership
361
+ const parent = me.getParent();
362
+ if (parent) {
363
+ return parent.getOwnerOfDataProperty(path)
364
+ }
466
365
 
467
- return plainData
366
+ return null
468
367
  }
469
368
 
470
369
  /**
@@ -472,13 +371,22 @@ class Provider extends Base {
472
371
  * @returns {Neo.state.Provider|null}
473
372
  */
474
373
  getParent() {
475
- let {parent} = this;
374
+ let me = this;
476
375
 
477
- if (parent) {
478
- return parent
376
+ // Access the internal value of the parent_ config directly.
377
+ // This avoids recursive calls to the getter.
378
+ if (me._parent) {
379
+ return me._parent
380
+ }
381
+
382
+ // If no explicit parent is set, try to find it dynamically via the component.
383
+ // Ensure this.component exists before trying to access its parent.
384
+ if (me.component) {
385
+ return me.component.parent?.getStateProvider() || null
479
386
  }
480
387
 
481
- return this.component.parent?.getStateProvider() || null
388
+ // No explicit parent and no component to derive it from.
389
+ return null
482
390
  }
483
391
 
484
392
  /**
@@ -505,10 +413,50 @@ class Provider extends Base {
505
413
  return parentStateProvider.getStore(key, originStateProvider)
506
414
  }
507
415
 
416
+ /**
417
+ * Checks if any data property in the hierarchy starts with the given path.
418
+ * This is used by the HierarchicalDataProxy to determine if it should return a nested proxy.
419
+ * @param {String} path The path to check (e.g., 'user').
420
+ * @returns {Boolean}
421
+ */
422
+ hasNestedDataStartingWith(path) {
423
+ const pathWithDot = `${path}.`;
424
+
425
+ if (Object.keys(this.#dataConfigs).some(key => key.startsWith(pathWithDot))) {
426
+ return true
427
+ }
428
+
429
+ return this.getParent()?.hasNestedDataStartingWith(path) || false
430
+ }
431
+
432
+ /**
433
+ * Returns the top-level data keys for a given path within this provider's data.
434
+ * @param {String} path The path to get keys for (e.g., 'user.address').
435
+ * @returns {String[]}
436
+ */
437
+ getTopLevelDataKeys(path) {
438
+ const keys = new Set();
439
+ const pathPrefix = path ? `${path}.` : '';
440
+
441
+ for (const fullPath in this.#dataConfigs) {
442
+ if (fullPath.startsWith(pathPrefix)) {
443
+ const relativePath = fullPath.substring(pathPrefix.length);
444
+ const topLevelKey = relativePath.split('.')[0];
445
+ if (topLevelKey) {
446
+ keys.add(topLevelKey);
447
+ }
448
+ }
449
+ }
450
+ return Array.from(keys);
451
+ }
452
+
508
453
  /**
509
454
  * Internal method to avoid code redundancy.
510
455
  * Use setData() or setDataAtSameLevel() instead.
511
456
  *
457
+ * This method handles setting data properties, including nested paths and Neo.data.Record instances.
458
+ * It determines the owning StateProvider in the hierarchy and delegates to #setConfigValue.
459
+ *
512
460
  * Passing an originStateProvider param will try to set each key on the closest property match
513
461
  * inside the parent stateProvider chain => setData()
514
462
  * Not passing it will set all values on the stateProvider where the method gets called => setDataAtSameLevel()
@@ -518,35 +466,54 @@ class Provider extends Base {
518
466
  * @protected
519
467
  */
520
468
  internalSetData(key, value, originStateProvider) {
521
- let me = this,
522
- data, keyLeaf, parentStateProvider, scope;
469
+ const me = this;
523
470
 
524
- if (Neo.isObject(value) && !value.isRecord) {
525
- Object.entries(value).forEach(([dataKey, dataValue]) => {
526
- me.internalSetData(`${key}.${dataKey}`, dataValue, originStateProvider)
527
- })
528
- } else if (Neo.isObject(key)) {
471
+ // If the value is a Neo.data.Record, treat it as an atomic value
472
+ // and set it directly without further recursive processing of its properties.
473
+ if (Neo.isObject(value) && value.isRecord) {
474
+ const
475
+ ownerDetails = me.getOwnerOfDataProperty(key),
476
+ targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
477
+
478
+ me.#setConfigValue(targetProvider, key, value, null);
479
+ return
480
+ }
481
+
482
+ // If the key is an object, iterate over its entries and recursively call internalSetData.
483
+ // This handles setting multiple properties at once (e.g., setData({prop1: val1, prop2: val2})).
484
+ if (Neo.isObject(key)) {
529
485
  Object.entries(key).forEach(([dataKey, dataValue]) => {
530
486
  me.internalSetData(dataKey, dataValue, originStateProvider)
531
- })
532
- } else {
533
- data = me.getDataScope(key);
534
- keyLeaf = data.key;
535
- scope = data.scope;
536
-
537
- if (scope?.hasOwnProperty(keyLeaf)) {
538
- scope[keyLeaf] = value
539
- } else {
540
- if (originStateProvider) {
541
- parentStateProvider = me.getParent();
487
+ });
488
+ return
489
+ }
542
490
 
543
- if (parentStateProvider) {
544
- parentStateProvider.internalSetData(key, value, originStateProvider)
545
- } else {
546
- originStateProvider.addDataProperty(key, value)
547
- }
548
- } else {
549
- me.addDataProperty(key, value)
491
+ // Handle single key/value pairs, including nested paths (e.g., 'user.firstName').
492
+ const
493
+ ownerDetails = me.getOwnerOfDataProperty(key),
494
+ targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me),
495
+ pathParts = key.split('.');
496
+
497
+ let currentPath = '',
498
+ currentConfig = null,
499
+ currentProvider = targetProvider;
500
+
501
+ for (let i = 0; i < pathParts.length; i++) {
502
+ const part = pathParts[i];
503
+ currentPath = currentPath ? `${currentPath}.${part}` : part;
504
+ currentConfig = currentProvider.getDataConfig(currentPath);
505
+
506
+ if (i === pathParts.length - 1) { // Last part of the path
507
+ // Set the value for the final property in the path.
508
+ me.#setConfigValue(currentProvider, currentPath, value, null)
509
+ } else { // Intermediate part of the path
510
+ // Ensure intermediate paths exist as objects. If not, create them.
511
+ // If an intermediate path exists but is not an object, overwrite it with an empty object.
512
+ if (!currentConfig) {
513
+ currentConfig = new Config({}); // Create an empty object config
514
+ currentProvider.#dataConfigs[currentPath] = currentConfig
515
+ } else if (!Neo.isObject(currentConfig.get())) {
516
+ currentConfig.set({})
550
517
  }
551
518
  }
552
519
  }
@@ -561,144 +528,40 @@ class Provider extends Base {
561
528
  return Neo.isString(value) && value.startsWith('stores.')
562
529
  }
563
530
 
564
- /**
565
- * Override this method to change the order configs are applied to this instance.
566
- * @param {Object} config
567
- * @param {Boolean} [preventOriginalConfig] True prevents the instance from getting an originalConfig property
568
- * @returns {Object} config
569
- */
570
- mergeConfig(config, preventOriginalConfig) {
571
- if (config.data) {
572
- config.data = Neo.merge(Neo.clone(this.constructor.config.data, true) || {}, config.data)
573
- }
574
-
575
- return super.mergeConfig(config, preventOriginalConfig)
576
- }
577
-
578
531
  /**
579
532
  * @param {String} key
580
533
  * @param {*} value
581
534
  * @param {*} oldValue
582
535
  */
583
536
  onDataPropertyChange(key, value, oldValue) {
584
- let me = this,
585
- binding = me.bindings && Neo.ns(key, false, me.bindings),
586
- component, config, hierarchyData, stateProvider;
587
-
588
- if (binding) {
589
- hierarchyData = {};
590
-
591
- Object.entries(binding).forEach(([componentId, configObject]) => {
592
- component = Neo.getComponent(componentId) || Neo.get(componentId); // timing issue: the cmp might not be registered inside manager.Component yet
593
- config = {};
594
- stateProvider = component.getStateProvider() || me;
595
-
596
- if (!hierarchyData[stateProvider.id]) {
597
- hierarchyData[stateProvider.id] = stateProvider.getHierarchyData()
598
- }
599
-
600
- Object.entries(configObject).forEach(([configField, formatter]) => {
601
- // we can not call me.callFormatter(), since a data property inside a parent stateProvider
602
- // could have changed which is relying on data properties inside a closer stateProvider
603
- config[configField] = stateProvider.callFormatter(formatter, hierarchyData[stateProvider.id])
604
- });
605
-
606
- component?.set(config)
607
- })
608
- }
609
-
610
- me.formulas && me.resolveFormulas({key, id: me.id, oldValue, value});
611
-
612
- me.fire('dataPropertyChange', {key, id: me.id, oldValue, value})
537
+ // Can be overridden by subclasses
613
538
  }
614
539
 
615
540
  /**
616
- * This method will assign binding values at the earliest possible point inside the component lifecycle.
617
- * It can not store bindings though, since child component ids most likely do not exist yet.
618
- * @param {Neo.component.Base} component=this.component
619
- */
620
- parseConfig(component=this.component) {
621
- let me = this,
622
- config = {};
623
-
624
- if (component.bind) {
625
- me.createBindings(component);
626
-
627
- Object.entries(component.bind).forEach(([key, value]) => {
628
- if (Neo.isObject(value)) {
629
- value.key = me.getFormatterVariables(value.value)[0];
630
- value = value.value
631
- }
632
-
633
- if (me.isStoreValue(value)) {
634
- me.resolveStore(component, key, value.substring(7)) // remove the "stores." at the start
635
- } else {
636
- config[key] = me.callFormatter(value)
637
- }
638
- });
639
-
640
- component.set(config)
641
- }
642
- }
643
-
644
- /**
645
- * Removes all bindings for a given component id inside this stateProvider as well as inside all parent stateProviders.
646
- * @param {String} componentId
541
+ * Recursively processes a data object, creating or updating Neo.core.Config instances
542
+ * for each property and storing them in the #dataConfigs map.
543
+ * @param {Object} obj The data object to process.
544
+ * @param {String} [path=''] The current path prefix for nested objects.
545
+ * @protected
647
546
  */
648
- removeBindings(componentId) {
547
+ processDataObject(obj, path = '') {
649
548
  let me = this;
650
549
 
651
- Object.entries(me.bindings).forEach(([dataProperty, binding]) => {
652
- delete binding[componentId]
653
- });
654
-
655
- me.getParent()?.removeBindings(componentId)
656
- }
550
+ Object.entries(obj).forEach(([key, value]) => {
551
+ const fullPath = path ? `${path}.${key}` : key;
657
552
 
658
- /**
659
- * Resolve the formulas initially and update, when data change
660
- * @param {Object} data data from event or null on initial call
661
- */
662
- resolveFormulas(data) {
663
- let me = this,
664
- {formulas} = me,
665
- initialRun = !data,
666
- affectFormula, bindObject, fn, key, result, value;
667
-
668
- if (formulas) {
669
- if (!initialRun && (!data.key || !data.value)) {
670
- console.warn('[StateProvider:formulas] missing key or value', data.key, data.value)
553
+ // Ensure a Config instance exists for the current fullPath
554
+ if (me.#dataConfigs[fullPath]) {
555
+ me.#dataConfigs[fullPath].set(value);
556
+ } else {
557
+ me.#dataConfigs[fullPath] = new Config(value);
671
558
  }
672
559
 
673
- for ([key, value] of Object.entries(formulas)) {
674
- affectFormula = true;
675
-
676
- // Check if the change affects a formula
677
- if (!initialRun) {
678
- affectFormula = Object.values(value.bind).includes(data.key)
679
- }
680
-
681
- if (affectFormula) {
682
- // Create Bind-Object and fill with new values
683
- bindObject = Neo.clone(value.bind);
684
- fn = value.get;
685
-
686
- Object.keys(bindObject).forEach((key, index) => {
687
- bindObject[key] = me.getData(bindObject[key])
688
- });
689
-
690
- // Calc the formula
691
- result = fn(bindObject);
692
-
693
- // Assign if no error or null
694
- if (isNaN(result)) {
695
- me.setData(key, null)
696
- } else {
697
- me.setData(key, result)
698
- }
699
- }
560
+ // If the value is a plain object, recursively process its properties
561
+ if (Neo.typeOf(value) === 'Object') {
562
+ me.processDataObject(value, fullPath);
700
563
  }
701
- }
564
+ });
702
565
  }
703
566
 
704
567
  /**
@@ -714,6 +577,34 @@ class Provider extends Base {
714
577
  }
715
578
  }
716
579
 
580
+ /**
581
+ * Helper function to set a config value and trigger reactivity.
582
+ * This method creates a new Config instance if one doesn't exist for the given path,
583
+ * or updates an existing one. It also triggers binding effects and calls onDataPropertyChange.
584
+ * @param {Neo.state.Provider} provider The StateProvider instance owning the config.
585
+ * @param {String} path The full path of the data property (e.g., 'user.firstname').
586
+ * @param {*} newValue The new value to set.
587
+ * @param {*} oldVal The old value (optional, used for initial setup).
588
+ * @private
589
+ */
590
+ #setConfigValue(provider, path, newValue, oldVal) {
591
+ let currentConfig = provider.getDataConfig(path),
592
+ oldValue = oldVal;
593
+
594
+ if (currentConfig) {
595
+ oldValue = currentConfig.get();
596
+ currentConfig.set(newValue);
597
+ } else {
598
+ currentConfig = new Config(newValue);
599
+ provider.#dataConfigs[path] = currentConfig;
600
+ // Trigger all binding effects to re-evaluate their dependencies
601
+ provider.#bindingEffects.forEach(effect => effect.run())
602
+ }
603
+
604
+ // Notify subscribers of the data property change.
605
+ provider.onDataPropertyChange(path, newValue, oldValue)
606
+ }
607
+
717
608
  /**
718
609
  * The method will assign all values to the closest stateProvider where it finds an existing key.
719
610
  * In case no match is found inside the parent chain, a new data property will get generated.