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

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 (44) hide show
  1. package/.github/RELEASE_NOTES/v10.0.0-beta.4.md +41 -0
  2. package/ServiceWorker.mjs +2 -2
  3. package/apps/portal/index.html +1 -1
  4. package/apps/portal/view/ViewportController.mjs +1 -1
  5. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  6. package/apps/portal/view/learn/MainContainerController.mjs +6 -6
  7. package/learn/guides/{Collections.md → datahandling/Collections.md} +6 -6
  8. package/learn/guides/datahandling/Grids.md +621 -0
  9. package/learn/guides/{Records.md → datahandling/Records.md} +4 -3
  10. package/learn/guides/{StateProviders.md → datahandling/StateProviders.md} +145 -1
  11. package/learn/guides/fundamentals/ExtendingNeoClasses.md +359 -0
  12. package/learn/guides/{Layouts.md → uibuildingblocks/Layouts.md} +40 -38
  13. package/learn/guides/{form_fields → userinteraction/form_fields}/ComboBox.md +3 -3
  14. package/learn/tree.json +63 -57
  15. package/package.json +2 -2
  16. package/src/DefaultConfig.mjs +2 -2
  17. package/src/Neo.mjs +37 -29
  18. package/src/collection/Base.mjs +29 -2
  19. package/src/component/Base.mjs +6 -16
  20. package/src/controller/Base.mjs +87 -63
  21. package/src/core/Base.mjs +72 -17
  22. package/src/core/Compare.mjs +3 -13
  23. package/src/core/Config.mjs +139 -0
  24. package/src/core/ConfigSymbols.mjs +3 -0
  25. package/src/core/Util.mjs +3 -18
  26. package/src/data/RecordFactory.mjs +22 -3
  27. package/src/util/Function.mjs +52 -5
  28. package/test/siesta/tests/ReactiveConfigs.mjs +112 -0
  29. package/learn/guides/ExtendingNeoClasses.md +0 -331
  30. /package/learn/guides/{Tables.md → datahandling/Tables.md} +0 -0
  31. /package/learn/guides/{ApplicationBootstrap.md → fundamentals/ApplicationBootstrap.md} +0 -0
  32. /package/learn/guides/{ConfigSystemDeepDive.md → fundamentals/ConfigSystemDeepDive.md} +0 -0
  33. /package/learn/guides/{DeclarativeComponentTreesVsImperativeVdom.md → fundamentals/DeclarativeComponentTreesVsImperativeVdom.md} +0 -0
  34. /package/learn/guides/{InstanceLifecycle.md → fundamentals/InstanceLifecycle.md} +0 -0
  35. /package/learn/guides/{MainThreadAddons.md → fundamentals/MainThreadAddons.md} +0 -0
  36. /package/learn/guides/{Mixins.md → specificfeatures/Mixins.md} +0 -0
  37. /package/learn/guides/{MultiWindow.md → specificfeatures/MultiWindow.md} +0 -0
  38. /package/learn/guides/{PortalApp.md → specificfeatures/PortalApp.md} +0 -0
  39. /package/learn/guides/{ComponentsAndContainers.md → uibuildingblocks/ComponentsAndContainers.md} +0 -0
  40. /package/learn/guides/{CustomComponents.md → uibuildingblocks/CustomComponents.md} +0 -0
  41. /package/learn/guides/{WorkingWithVDom.md → uibuildingblocks/WorkingWithVDom.md} +0 -0
  42. /package/learn/guides/{Forms.md → userinteraction/Forms.md} +0 -0
  43. /package/learn/guides/{events → userinteraction/events}/CustomEvents.md +0 -0
  44. /package/learn/guides/{events → userinteraction/events}/DomEvents.md +0 -0
@@ -2,8 +2,11 @@ import Base from '../core/Base.mjs';
2
2
  import HashHistory from '../util/HashHistory.mjs';
3
3
 
4
4
  const
5
- amountSlashesRegex = /\//g,
6
- routeParamRegex = /{[^\s/]+}/g
5
+ regexAmountSlashes = /\//g,
6
+ // Regex to extract the parameter name from a single route segment (e.g., {*itemId} -> itemId)
7
+ regexParamNameExtraction = /{(\*|\.\.\.)?([^}]+)}/,
8
+ // Regex to match route parameters like {paramName}, {*paramName}, or {...paramName}
9
+ regexRouteParam = /{(\*|\.\.\.)?([^}]+)}/g;
7
10
 
8
11
  /**
9
12
  * @class Neo.controller.Base
@@ -22,25 +25,33 @@ class Controller extends Base {
22
25
  */
23
26
  ntype: 'controller',
24
27
  /**
25
- * If the URL does not contain a hash value when creating this controller instance,
26
- * neo will set this hash value for us.
28
+ * If the URL does not contain a hash value when this controller instance is created,
29
+ * Neo.mjs will automatically set this hash value, ensuring a default route is active.
27
30
  * @member {String|null} defaultHash=null
28
31
  */
29
32
  defaultHash: null,
30
33
  /**
34
+ * Specifies the handler method to be invoked when no other defined route matches the URL hash.
35
+ * This acts as a fallback for unhandled routes.
31
36
  * @member {String|null} defaultRoute=null
32
37
  */
33
38
  defaultRoute: null,
34
39
  /**
40
+ * Internal map of compiled regular expressions for each route, used for efficient hash matching.
41
+ * @protected
35
42
  * @member {Object} handleRoutes={}
36
43
  */
37
44
  handleRoutes: {},
38
45
  /**
46
+ * Defines the routing rules for the controller. Keys are route patterns, and values are either
47
+ * handler method names (String) or objects containing `handler` and optional `preHandler` method names.
48
+ * Route patterns can include parameters like `{paramName}` and wildcards like `{*paramName}` for nested paths.
39
49
  * @example
40
50
  * routes: {
41
51
  * '/home' : 'handleHomeRoute',
42
52
  * '/users/{userId}' : {handler: 'handleUserRoute', preHandler: 'preHandleUserRoute'},
43
53
  * '/users/{userId}/posts/{postId}': 'handlePostRoute',
54
+ * '/learn/{*itemId}' : 'onLearnRoute', // Captures nested paths like /learn/gettingstarted/Workspaces
44
55
  * 'default' : 'handleOtherRoutes'
45
56
  * }
46
57
  * @member {Object} routes_={}
@@ -49,16 +60,18 @@ class Controller extends Base {
49
60
  }
50
61
 
51
62
  /**
63
+ * Creates a new Controller instance and registers its `onHashChange` method
64
+ * to listen for changes in the browser's URL hash.
52
65
  * @param {Object} config
53
66
  */
54
67
  construct(config) {
55
68
  super.construct(config);
56
-
57
69
  HashHistory.on('change', this.onHashChange, this)
58
70
  }
59
71
 
60
72
  /**
61
- * Triggered after the routes config got changed
73
+ * Processes the defined routes configuration, compiling route patterns into regular expressions
74
+ * for efficient matching and sorting them by specificity (more slashes first).
62
75
  * @param {Object} value
63
76
  * @param {Object} oldValue
64
77
  * @protected
@@ -78,7 +91,13 @@ class Controller extends Base {
78
91
  if (key.toLowerCase() === 'default'){
79
92
  me.defaultRoute = value[key]
80
93
  } else {
81
- me.handleRoutes[key] = new RegExp(key.replace(routeParamRegex, '([\\w-.]+)')+'$')
94
+ me.handleRoutes[key] = new RegExp(key.replace(regexRouteParam, (match, isWildcard, paramName) => {
95
+ if (isWildcard || paramName.startsWith('*')) {
96
+ return '(.*)'
97
+ } else {
98
+ return '([\\w-.]+)'
99
+ }
100
+ }))
82
101
  }
83
102
  })
84
103
  }
@@ -88,21 +107,19 @@ class Controller extends Base {
88
107
  */
89
108
  destroy(...args) {
90
109
  HashHistory.un('change', this.onHashChange, this);
91
-
92
110
  super.destroy(...args)
93
111
  }
94
112
 
95
113
  /**
96
- *
114
+ * @returns {Promise<void>}
97
115
  */
98
- async onConstructed() {
116
+ async initAsync() {
117
+ await super.initAsync();
118
+
99
119
  let me = this,
100
120
  {defaultHash, windowId} = me,
101
121
  currentHash = HashHistory.first(windowId);
102
122
 
103
- // get outside the construction chain => a related cmp & vm has to be constructed too
104
- await me.timeout(1);
105
-
106
123
  if (currentHash) {
107
124
  if (currentHash.windowId === windowId) {
108
125
  await me.onHashChange(currentHash, null)
@@ -118,9 +135,10 @@ class Controller extends Base {
118
135
  }
119
136
 
120
137
  /**
121
- * Placeholder method which gets triggered when the hash inside the browser url changes
122
- * @param {Object} value
123
- * @param {Object} oldValue
138
+ * Handles changes in the browser's URL hash. It identifies the most specific matching route
139
+ * and dispatches the corresponding handler, optionally executing a preHandler first.
140
+ * @param {Object} value - The new hash history entry.
141
+ * @param {Object} oldValue - The previous hash history entry.
124
142
  */
125
143
  async onHashChange(value, oldValue) {
126
144
  // We only want to trigger hash changes for the same browser window (SharedWorker context)
@@ -129,63 +147,67 @@ class Controller extends Base {
129
147
  }
130
148
 
131
149
  let me = this,
132
- counter = 0,
133
- hasRouteBeenFound = false,
134
150
  {handleRoutes, routes} = me,
135
151
  routeKeys = Object.keys(handleRoutes),
136
- routeKeysLength = routeKeys.length,
137
- arrayParamIds, arrayParamValues, handler, key, paramObject, preHandler, responsePreHandler, result, route;
152
+ bestMatch = null,
153
+ bestMatchKey = null,
154
+ bestMatchParams = null;
138
155
 
139
- while (routeKeysLength > 0 && counter < routeKeysLength && !hasRouteBeenFound) {
140
- key = routeKeys[counter];
141
- handler = null;
142
- preHandler = null;
143
- responsePreHandler = null;
144
- paramObject = {};
145
- result = value.hashString.match(handleRoutes[key]);
156
+ for (let i = 0; i < routeKeys.length; i++) {
157
+ const key = routeKeys[i];
158
+ const result = value.hashString.match(handleRoutes[key]);
146
159
 
147
160
  if (result) {
148
- arrayParamIds = key.match(routeParamRegex);
149
- arrayParamValues = result.splice(1, result.length - 1);
150
-
151
- if (arrayParamIds && arrayParamIds.length !== arrayParamValues.length) {
152
- throw 'Number of IDs and number of Values do not match'
161
+ const
162
+ arrayParamIds = key.match(regexRouteParam),
163
+ arrayParamValues = result.splice(1, result.length - 1),
164
+ paramObject = {};
165
+
166
+ if (arrayParamIds) {
167
+ for (let j = 0; j < arrayParamIds.length; j++) {
168
+ const paramMatch = arrayParamIds[j].match(regexParamNameExtraction);
169
+
170
+ if (paramMatch) {
171
+ const paramName = paramMatch[2];
172
+ paramObject[paramName] = arrayParamValues[j];
173
+ }
174
+ }
153
175
  }
154
176
 
155
- for (let i = 0; arrayParamIds && i < arrayParamIds.length; i++) {
156
- paramObject[arrayParamIds[i].substring(1, arrayParamIds[i].length - 1)] = arrayParamValues[i]
177
+ // Logic to determine the best matching route:
178
+ // 1. Prioritize routes that match a longer string (more specific match).
179
+ // 2. If lengths are equal, prioritize routes with more slashes (deeper nesting).
180
+ if (!bestMatch || (result[0].length > bestMatch[0].length) ||
181
+ (result[0].length === bestMatch[0].length && (key.match(regexAmountSlashes) || []).length > (bestMatchKey.match(regexAmountSlashes) || []).length)) {
182
+ bestMatch = result;
183
+ bestMatchKey = key;
184
+ bestMatchParams = paramObject;
157
185
  }
186
+ }
187
+ }
158
188
 
159
- route = routes[key];
160
-
161
- if (Neo.isString(route)) {
162
- handler = route;
163
- responsePreHandler = true
164
- } else if (Neo.isObject(route)) {
165
- handler = route.handler;
166
- preHandler = route.preHandler
167
- }
189
+ if (bestMatch) {
190
+ const route = routes[bestMatchKey];
191
+ let handler = null,
192
+ preHandler = null;
168
193
 
169
- hasRouteBeenFound = true
194
+ if (Neo.isString(route)) {
195
+ handler = route
196
+ } else if (Neo.isObject(route)) {
197
+ handler = route.handler;
198
+ preHandler = route.preHandler
170
199
  }
171
200
 
172
- counter++
173
- }
201
+ let responsePreHandler = true;
174
202
 
175
- // execute
176
- if (hasRouteBeenFound) {
177
203
  if (preHandler) {
178
- responsePreHandler = await me[preHandler]?.call(me, paramObject, value, oldValue)
179
- } else {
180
- responsePreHandler = true
204
+ responsePreHandler = await me[preHandler]?.call(me, bestMatchParams, value, oldValue)
181
205
  }
182
206
 
183
207
  if (responsePreHandler) {
184
- await me[handler]?.call(me, paramObject, value, oldValue)
208
+ await me[handler]?.call(me, bestMatchParams, value, oldValue)
185
209
  }
186
- }
187
-
188
- if (routeKeys.length > 0 && !hasRouteBeenFound) {
210
+ } else {
189
211
  if (me.defaultRoute) {
190
212
  me[me.defaultRoute]?.(value, oldValue)
191
213
  } else {
@@ -195,22 +217,24 @@ class Controller extends Base {
195
217
  }
196
218
 
197
219
  /**
198
- * Placeholder method which gets triggered when an invalid route is called
199
- * @param {Object} value
200
- * @param {Object} oldValue
220
+ * Placeholder method invoked when no matching route is found for the current URL hash.
221
+ * Controllers can override this to implement custom behavior for unhandled routes.
222
+ * @param {Object} value - The current hash history entry.
223
+ * @param {Object} oldValue - The previous hash history entry.
201
224
  */
202
225
  onNoRouteFound(value, oldValue) {
203
226
 
204
227
  }
205
228
 
206
229
  /**
207
- * Internal helper method to sort routes by their amount of slashes
208
- * @param {String} route1
209
- * @param {String} route2
210
- * @returns {Number}
230
+ * Internal helper method to sort routes by their specificity.
231
+ * Routes with more slashes are considered more specific and are prioritized.
232
+ * @param {String} route1 - The first route string to compare.
233
+ * @param {String} route2 - The second route string to compare.
234
+ * @returns {Number} A negative value if route1 is more specific, a positive value if route2 is more specific, or 0 if they have equal specificity.
211
235
  */
212
236
  #sortRoutes(route1, route2) {
213
- return (route1.match(amountSlashesRegex) || []).length - (route2.match(amountSlashesRegex)|| []).length
237
+ return (route1.match(regexAmountSlashes) || []).length - (route2.match(regexAmountSlashes)|| []).length
214
238
  }
215
239
  }
216
240
 
package/src/core/Base.mjs CHANGED
@@ -1,4 +1,8 @@
1
1
  import {buffer, debounce, intercept, resolveCallback, throttle} from '../util/Function.mjs';
2
+ import Compare from '../core/Compare.mjs';
3
+ import Util from '../core/Util.mjs';
4
+ import Config from './Config.mjs';
5
+ import {isDescriptor} from './ConfigSymbols.mjs';
2
6
  import IdGenerator from './IdGenerator.mjs'
3
7
 
4
8
  const configSymbol = Symbol.for('configSymbol'),
@@ -125,6 +129,12 @@ class Base {
125
129
  remote_: null
126
130
  }
127
131
 
132
+ /**
133
+ * A private field to store the Config controller instances.
134
+ * @member {Object} #configs={}
135
+ * @private
136
+ */
137
+ #configs = {};
128
138
  /**
129
139
  * Internal cache for all timeout ids when using this.timeout()
130
140
  * @member {Number[]} timeoutIds=[]
@@ -133,8 +143,39 @@ class Base {
133
143
  #timeoutIds = []
134
144
 
135
145
  /**
136
- * Applies the observable mixin if needed, grants remote access if needed.
137
- * @param {Object} config={}
146
+ * The main initializer for all Neo.mjs classes, invoked by `Neo.create()`.
147
+ * NOTE: This is not the native `constructor()`, which is called without arguments by `Neo.create()` first.
148
+ *
149
+ * This method orchestrates the entire instance initialization process, including
150
+ * the setup of the powerful and flexible config system.
151
+ *
152
+ * The `config` parameter is a single object that can contain different types of properties,
153
+ * which are processed in a specific order to ensure consistency and predictability:
154
+ *
155
+ * 1. **Public Class Fields & Other Properties:** Any key in the `config` object that is NOT
156
+ * defined in the class's `static config` hierarchy is considered a public field or a
157
+ * dynamic property. These are assigned directly to the instance (`this.myField = value`)
158
+ * at the very beginning. This is crucial so that subsequent config hooks (like `afterSet*`)
159
+ * can access their latest values.
160
+ *
161
+ * 2. **Reactive Configs:** A property is considered reactive if it is defined with a trailing
162
+ * underscore (e.g., `myValue_`) in the `static config` of **any class in the inheritance
163
+ * chain**. Subclasses can provide new default values for these configs without the
164
+ * underscore, and they will still be reactive. Their values are applied via generated
165
+ * setters, triggering `beforeSet*` and `afterSet*` hooks, and they are wrapped in a
166
+ * `Neo.core.Config` instance to enable subscription-based reactivity.
167
+ *
168
+ * 3. **Non-Reactive Configs:** Properties defined in `static config` without a trailing
169
+ * underscore in their entire inheritance chain. Their default values are applied directly
170
+ * to the class **prototype**, making them shared across all instances and allowing for
171
+ * run-time modifications (prototypal inheritance). When a new value is passed to this
172
+ * method, it creates an instance-specific property that shadows the prototype value.
173
+ *
174
+ * This method also initializes the observable mixin (if applicable) and schedules asynchronous
175
+ * logic like `initAsync()` (which handles remote method access) to run after the synchronous
176
+ * construction chain is complete.
177
+ *
178
+ * @param {Object} config={} The initial configuration object for the instance.
138
179
  */
139
180
  construct(config={}) {
140
181
  let me = this;
@@ -152,13 +193,9 @@ class Base {
152
193
  }
153
194
  });
154
195
 
155
- me.createId(config.id || me.id);
196
+ me.id = config.id || IdGenerator.getId(this.getIdKey());
156
197
  delete config.id;
157
198
 
158
- if (me.constructor.config) {
159
- delete me.constructor.config.id
160
- }
161
-
162
199
  me.getStaticConfig('observable') && me.initObservable(config);
163
200
 
164
201
  // assign class field values prior to configs
@@ -342,16 +379,6 @@ class Base {
342
379
  this.__proto__.constructor.overwrittenMethods[methodName].call(this, ...args)
343
380
  }
344
381
 
345
- /**
346
- * Uses the IdGenerator to create an id if a static one is not explicitly set.
347
- * Registers the instance to manager.Instance if this one is already created,
348
- * otherwise stores it inside a tmp map.
349
- * @param {String} id
350
- */
351
- createId(id) {
352
- this.id = id || IdGenerator.getId(this.getIdKey())
353
- }
354
-
355
382
  /**
356
383
  * Unregisters this instance from Neo.manager.Instance
357
384
  * and removes all object entries from this instance
@@ -386,6 +413,22 @@ class Base {
386
413
  me.isDestroyed = true
387
414
  }
388
415
 
416
+ /**
417
+ * A public method to access the underlying Config controller.
418
+ * This enables advanced interactions like subscriptions.
419
+ * @param {String} key The name of the config property (e.g., 'items').
420
+ * @returns {Config|undefined} The Config instance, or undefined if not found.
421
+ */
422
+ getConfig(key) {
423
+ let me = this;
424
+
425
+ if (!me.#configs[key] && me.isConfig(key)) {
426
+ me.#configs[key] = new Config()
427
+ }
428
+
429
+ return me.#configs[key]
430
+ }
431
+
389
432
  /**
390
433
  * Used inside createId() as the default value passed to the IdGenerator.
391
434
  * Override this method as needed.
@@ -443,6 +486,7 @@ class Base {
443
486
 
444
487
  me.isConfiguring = true;
445
488
  Object.assign(me[configSymbol], me.mergeConfig(config, preventOriginalConfig));
489
+ delete me[configSymbol].id;
446
490
  me.processConfigs();
447
491
  me.isConfiguring = false;
448
492
  }
@@ -475,6 +519,17 @@ class Base {
475
519
  return !this.isDestroyed
476
520
  }
477
521
 
522
+ /**
523
+ * @param {String} key
524
+ * @returns {Boolean}
525
+ */
526
+ isConfig(key) {
527
+ // A config is considered "reactive" if it has a generated property setter
528
+ // AND it is present as a defined config in the merged static config hierarchy.
529
+ // Neo.setupClass() removes the underscore from the static config keys.
530
+ return Neo.hasPropertySetter(this, key) && (key in this.constructor.config);
531
+ }
532
+
478
533
  /**
479
534
  * Override this method to change the order configs are applied to this instance.
480
535
  * @param {Object} config
@@ -1,18 +1,7 @@
1
- import Base from '../core/Base.mjs';
2
-
3
1
  /**
4
2
  * @class Neo.core.Compare
5
- * @extends Neo.core.Base
6
3
  */
7
- class Compare extends Base {
8
- static config = {
9
- /**
10
- * @member {String} className='Neo.core.Compare'
11
- * @protected
12
- */
13
- className: 'Neo.core.Compare'
14
- }
15
-
4
+ class Compare {
16
5
  /**
17
6
  * Storing the comparison method names by data type
18
7
  * @member {Object} map
@@ -174,7 +163,8 @@ class Compare extends Base {
174
163
  }
175
164
  }
176
165
 
177
- Compare = Neo.setupClass(Compare);
166
+ const ns = Neo.ns('Neo.core', true);
167
+ ns.Compare = Compare;
178
168
 
179
169
  // alias
180
170
  Neo.isEqual = Compare.isEqual;
@@ -0,0 +1,139 @@
1
+ import {isDescriptor} from './ConfigSymbols.mjs';
2
+
3
+ /**
4
+ * @src/util/ClassSystem.mjs Neo.core.Config
5
+ * @private
6
+ * @internal
7
+ *
8
+ * Represents an observable container for a config property.
9
+ * This class manages the value of a config, its subscribers, and custom behaviors
10
+ * like merge strategies and equality checks defined via a descriptor object.
11
+ *
12
+ * The primary purpose of this class is to enable fine-grained reactivity and
13
+ * decoupled cross-instance state sharing within the Neo.mjs framework.
14
+ */
15
+ class Config {
16
+ /**
17
+ * The internal value of the config property.
18
+ * @private
19
+ * @apps/portal/view/about/MemberContainer.mjs {any} #value
20
+ */
21
+ #value;
22
+
23
+ /**
24
+ * A Set to store callback functions that subscribe to changes in this config's value.
25
+ * @private
26
+ * @apps/portal/view/about/MemberContainer.mjs {Set<Function>} #subscribers
27
+ */
28
+ #subscribers = new Set();
29
+
30
+ /**
31
+ * The strategy to use when merging new values into this config.
32
+ * Defaults to 'deep'. Can be overridden via a descriptor.
33
+ * @apps/portal/view/about/MemberContainer.mjs {string} mergeStrategy
34
+ */
35
+ mergeStrategy = 'deep';
36
+
37
+ /**
38
+ * The function used to compare new and old values for equality.
39
+ * Defaults to `Neo.isEqual`. Can be overridden via a descriptor.
40
+ * @apps/portal/view/about/MemberContainer.mjs {Function} isEqual
41
+ */
42
+ isEqual = Neo.isEqual;
43
+
44
+ /**
45
+ * Creates an instance of Config.
46
+ * @param {any|Object} configObject - The initial value for the config.
47
+ */
48
+ constructor(configObject) {
49
+ if (Neo.isObject(configObject) && configObject[isDescriptor] === true) {
50
+ this.initDescriptor(configObject)
51
+ } else {
52
+ this.#value = configObject
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Gets the current value of the config property.
58
+ * @returns {any} The current value.
59
+ */
60
+ get() {
61
+ return this.#value;
62
+ }
63
+
64
+ /**
65
+ * Initializes the `Config` instance using a descriptor object.
66
+ * Extracts `mergeStrategy` and `isEqual` from the descriptor.
67
+ * The internal `#value` is NOT set by this method.
68
+ * @param {Object} descriptor - The descriptor object for the config.
69
+ * @param {any} descriptor.value - The default value for the config (not set by this method).
70
+ * @param {string} [descriptor.merge='deep'] - The merge strategy.
71
+ * @param {Function} [descriptor.isEqual=Neo.isEqual] - The equality comparison function.
72
+ */
73
+ initDescriptor({isEqual, merge, value}) {
74
+ let me = this;
75
+
76
+ me.#value = value
77
+ me.mergeStrategy = merge || me.mergeStrategy;
78
+ me.isEqual = isEqual || me.isEqual;
79
+ }
80
+
81
+ /**
82
+ * Notifies all subscribed callbacks about a change in the config's value.
83
+ * @param {any} newValue - The new value of the config.
84
+ * @param {any} oldValue - The old value of the config.
85
+ */
86
+ notify(newValue, oldValue) {
87
+ for (const callback of this.#subscribers) {
88
+ callback(newValue, oldValue);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Sets a new value for the config property.
94
+ * This method performs an equality check using `this.isEqual` before updating the value.
95
+ * If the value has changed, it updates `#value` and notifies all subscribers.
96
+ * @param {any} newValue - The new value to set.
97
+ * @returns {Boolean} True if the value changed, false otherwise.
98
+ */
99
+ set(newValue) {
100
+ if (newValue === undefined) return false; // Preserve original behavior for undefined
101
+
102
+ const
103
+ me = this,
104
+ oldValue = me.#value;
105
+
106
+ // The setter automatically uses the configured equality check
107
+ if (!me.isEqual(newValue, oldValue)) {
108
+ me.#value = newValue;
109
+ me.notify(newValue, oldValue);
110
+ return true
111
+ }
112
+
113
+ return false
114
+ }
115
+
116
+ /**
117
+ * Sets the internal value of the config property directly, without performing
118
+ * an equality check or notifying subscribers.
119
+ * This method is intended for internal framework use where direct assignment
120
+ * is necessary (e.g., during initial setup or specific internal optimizations).
121
+ * @param {any} newValue - The new value to set directly.
122
+ */
123
+ setRaw(newValue) {
124
+ this.#value = newValue
125
+ }
126
+
127
+ /**
128
+ * Subscribes a callback function to changes in this config's value.
129
+ * The callback will be invoked with `(newValue, oldValue)` whenever the config changes.
130
+ * @param {Function} callback - The function to call when the config value changes.
131
+ * @returns {Function} A cleanup function to unsubscribe the callback.
132
+ */
133
+ subscribe(callback) {
134
+ this.#subscribers.add(callback);
135
+ return () => this.#subscribers.delete(callback)
136
+ }
137
+ }
138
+
139
+ export default Config;
@@ -0,0 +1,3 @@
1
+
2
+ // e.g., in a new file src/core/ConfigSymbols.mjs
3
+ export const isDescriptor = Symbol.for('Neo.Config.isDescriptor');
package/src/core/Util.mjs CHANGED
@@ -1,10 +1,7 @@
1
- import Base from './Base.mjs';
2
-
3
1
  /**
4
2
  * @class Neo.core.Util
5
- * @extends Neo.core.Base
6
3
  */
7
- class Util extends Base {
4
+ class Util {
8
5
  /**
9
6
  * A regex to remove camel case syntax
10
7
  * @member {RegExp} decamelRegEx=/([a-z])([A-Z])/g
@@ -13,19 +10,6 @@ class Util extends Base {
13
10
  */
14
11
  static decamelRegEx = /([a-z])([A-Z])/g
15
12
 
16
- static config = {
17
- /**
18
- * @member {String} className='Neo.core.Util'
19
- * @protected
20
- */
21
- className: 'Neo.core.Util',
22
- /**
23
- * @member {String} ntype='core-util'
24
- * @protected
25
- */
26
- ntype: 'core-util'
27
- }
28
-
29
13
  /**
30
14
  * @param {Object} scope
31
15
  * @param {String[]} values
@@ -229,7 +213,8 @@ class Util extends Base {
229
213
  }
230
214
  }
231
215
 
232
- Util = Neo.setupClass(Util);
216
+ const ns = Neo.ns('Neo.core', true);
217
+ ns.Util = Util;
233
218
 
234
219
  // aliases
235
220
  Neo.applyFromNs(Neo, Util, {
@@ -81,7 +81,7 @@ class RecordFactory extends Base {
81
81
  return this[dataSymbol][fieldName]
82
82
  },
83
83
  set(value) {
84
- instance.setRecordFields({
84
+ this.notifyChange({
85
85
  fields: {[fieldPath]: instance.parseRecordValue({record: this, field, value})},
86
86
  model,
87
87
  record: this
@@ -193,6 +193,25 @@ class RecordFactory extends Base {
193
193
  return null
194
194
  }
195
195
 
196
+ /**
197
+ * The single source of truth for record field changes.
198
+ * Executes instance.setRecordFields(), and can get used via:
199
+ * - Neo.util.Function:createSequence()
200
+ * - Neo.util.Function:intercept(),
201
+ * to "listen" to field changes
202
+ * @param {Object} data
203
+ * @param {Object} data.fields
204
+ * @param {Neo.data.Model} data.model
205
+ * @param {Object} data.record
206
+ * @param {Boolean} silent=false
207
+ * @returns {Object}
208
+ */
209
+ notifyChange(data, silent=false) {
210
+ const param = {...data, silent}
211
+ instance.setRecordFields(param);
212
+ return param
213
+ }
214
+
196
215
  /**
197
216
  * Bulk-update multiple record fields at once
198
217
  * @param {Object} fields
@@ -207,7 +226,7 @@ class RecordFactory extends Base {
207
226
  * @param {Object} fields
208
227
  */
209
228
  set(fields) {
210
- instance.setRecordFields({fields, model, record: this})
229
+ this.notifyChange({fields, model, record: this})
211
230
  }
212
231
 
213
232
  /**
@@ -225,7 +244,7 @@ class RecordFactory extends Base {
225
244
  * @param {Object} fields
226
245
  */
227
246
  setSilent(fields) {
228
- instance.setRecordFields({fields, model, record: this, silent: true})
247
+ this.notifyChange({fields, model, record: this}, true)
229
248
  }
230
249
 
231
250
  /**