neo.mjs 10.0.0-beta.3 → 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 (76) 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/examples/button/effect/MainContainer.mjs +207 -0
  8. package/examples/button/effect/app.mjs +6 -0
  9. package/examples/button/effect/index.html +11 -0
  10. package/examples/button/effect/neo-config.json +6 -0
  11. package/learn/guides/{Collections.md → datahandling/Collections.md} +6 -6
  12. package/learn/guides/datahandling/Grids.md +621 -0
  13. package/learn/guides/{Records.md → datahandling/Records.md} +4 -3
  14. package/learn/guides/{StateProviders.md → datahandling/StateProviders.md} +146 -1
  15. package/learn/guides/fundamentals/DeclarativeVDOMWithEffects.md +166 -0
  16. package/learn/guides/fundamentals/ExtendingNeoClasses.md +359 -0
  17. package/learn/guides/{Layouts.md → uibuildingblocks/Layouts.md} +40 -38
  18. package/learn/guides/{form_fields → userinteraction/form_fields}/ComboBox.md +3 -3
  19. package/learn/tree.json +64 -57
  20. package/package.json +3 -3
  21. package/src/DefaultConfig.mjs +2 -2
  22. package/src/Neo.mjs +244 -88
  23. package/src/button/Effect.mjs +435 -0
  24. package/src/collection/Base.mjs +35 -3
  25. package/src/component/Base.mjs +72 -61
  26. package/src/container/Base.mjs +28 -24
  27. package/src/controller/Base.mjs +87 -63
  28. package/src/core/Base.mjs +207 -33
  29. package/src/core/Compare.mjs +3 -13
  30. package/src/core/Config.mjs +230 -0
  31. package/src/core/ConfigSymbols.mjs +3 -0
  32. package/src/core/Effect.mjs +127 -0
  33. package/src/core/EffectBatchManager.mjs +68 -0
  34. package/src/core/EffectManager.mjs +38 -0
  35. package/src/core/Util.mjs +3 -18
  36. package/src/data/RecordFactory.mjs +22 -3
  37. package/src/grid/Container.mjs +8 -4
  38. package/src/grid/column/Component.mjs +1 -1
  39. package/src/state/Provider.mjs +343 -452
  40. package/src/state/createHierarchicalDataProxy.mjs +124 -0
  41. package/src/tab/header/EffectButton.mjs +75 -0
  42. package/src/util/Function.mjs +52 -5
  43. package/src/vdom/Helper.mjs +9 -10
  44. package/src/vdom/VNode.mjs +1 -1
  45. package/src/worker/App.mjs +0 -5
  46. package/test/siesta/siesta.js +32 -0
  47. package/test/siesta/tests/CollectionBase.mjs +10 -10
  48. package/test/siesta/tests/VdomHelper.mjs +22 -59
  49. package/test/siesta/tests/config/AfterSetConfig.mjs +100 -0
  50. package/test/siesta/tests/config/Basic.mjs +149 -0
  51. package/test/siesta/tests/config/CircularDependencies.mjs +166 -0
  52. package/test/siesta/tests/config/CustomFunctions.mjs +69 -0
  53. package/test/siesta/tests/config/Hierarchy.mjs +94 -0
  54. package/test/siesta/tests/config/MemoryLeak.mjs +92 -0
  55. package/test/siesta/tests/config/MultiLevelHierarchy.mjs +85 -0
  56. package/test/siesta/tests/core/Effect.mjs +131 -0
  57. package/test/siesta/tests/core/EffectBatching.mjs +322 -0
  58. package/test/siesta/tests/neo/MixinStaticConfig.mjs +138 -0
  59. package/test/siesta/tests/state/Provider.mjs +537 -0
  60. package/test/siesta/tests/state/createHierarchicalDataProxy.mjs +217 -0
  61. package/learn/guides/ExtendingNeoClasses.md +0 -331
  62. /package/learn/guides/{Tables.md → datahandling/Tables.md} +0 -0
  63. /package/learn/guides/{ApplicationBootstrap.md → fundamentals/ApplicationBootstrap.md} +0 -0
  64. /package/learn/guides/{ConfigSystemDeepDive.md → fundamentals/ConfigSystemDeepDive.md} +0 -0
  65. /package/learn/guides/{DeclarativeComponentTreesVsImperativeVdom.md → fundamentals/DeclarativeComponentTreesVsImperativeVdom.md} +0 -0
  66. /package/learn/guides/{InstanceLifecycle.md → fundamentals/InstanceLifecycle.md} +0 -0
  67. /package/learn/guides/{MainThreadAddons.md → fundamentals/MainThreadAddons.md} +0 -0
  68. /package/learn/guides/{Mixins.md → specificfeatures/Mixins.md} +0 -0
  69. /package/learn/guides/{MultiWindow.md → specificfeatures/MultiWindow.md} +0 -0
  70. /package/learn/guides/{PortalApp.md → specificfeatures/PortalApp.md} +0 -0
  71. /package/learn/guides/{ComponentsAndContainers.md → uibuildingblocks/ComponentsAndContainers.md} +0 -0
  72. /package/learn/guides/{CustomComponents.md → uibuildingblocks/CustomComponents.md} +0 -0
  73. /package/learn/guides/{WorkingWithVDom.md → uibuildingblocks/WorkingWithVDom.md} +0 -0
  74. /package/learn/guides/{Forms.md → userinteraction/Forms.md} +0 -0
  75. /package/learn/guides/{events → userinteraction/events}/CustomEvents.md +0 -0
  76. /package/learn/guides/{events → userinteraction/events}/DomEvents.md +0 -0
package/src/core/Base.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  import {buffer, debounce, intercept, resolveCallback, throttle} from '../util/Function.mjs';
2
- import IdGenerator from './IdGenerator.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';
6
+ import IdGenerator from './IdGenerator.mjs';
7
+ import EffectBatchManager from './EffectBatchManager.mjs';
3
8
 
4
9
  const configSymbol = Symbol.for('configSymbol'),
5
10
  forceAssignConfigs = Symbol('forceAssignConfigs'),
@@ -94,12 +99,19 @@ class Base {
94
99
  * @protected
95
100
  */
96
101
  isConstructed: false,
102
+ /**
103
+ * This config will be set to `true` as the very first action within the `destroy()` method.
104
+ * Effects can observe this config to clean themselves up.
105
+ * @member {Boolean} isDestroying_=false
106
+ * @protected
107
+ */
108
+ isDestroying_: false,
97
109
  /**
98
110
  * The config will get set to `true` once the Promise of `async initAsync()` is resolved.
99
111
  * You can use `afterSetIsReady()` to get notified once the ready state is reached.
100
112
  * Since not all classes use the Observable mixin, Neo will not fire an event.
101
113
  * method body.
102
- * @member {Boolean} isReady=false
114
+ * @member {Boolean} isReady_=false
103
115
  */
104
116
  isReady_: false,
105
117
  /**
@@ -125,6 +137,18 @@ class Base {
125
137
  remote_: null
126
138
  }
127
139
 
140
+ /**
141
+ * A private field to store the Config controller instances.
142
+ * @member {Object} #configs={}
143
+ * @private
144
+ */
145
+ #configs = {};
146
+ /**
147
+ * Internal cache for all config subscription cleanup functions.
148
+ * @member {Function[]} #configSubscriptionCleanups=[]
149
+ * @private
150
+ */
151
+ #configSubscriptionCleanups = []
128
152
  /**
129
153
  * Internal cache for all timeout ids when using this.timeout()
130
154
  * @member {Number[]} timeoutIds=[]
@@ -133,8 +157,39 @@ class Base {
133
157
  #timeoutIds = []
134
158
 
135
159
  /**
136
- * Applies the observable mixin if needed, grants remote access if needed.
137
- * @param {Object} config={}
160
+ * The main initializer for all Neo.mjs classes, invoked by `Neo.create()`.
161
+ * NOTE: This is not the native `constructor()`, which is called without arguments by `Neo.create()` first.
162
+ *
163
+ * This method orchestrates the entire instance initialization process, including
164
+ * the setup of the powerful and flexible config system.
165
+ *
166
+ * The `config` parameter is a single object that can contain different types of properties,
167
+ * which are processed in a specific order to ensure consistency and predictability:
168
+ *
169
+ * 1. **Public Class Fields & Other Properties:** Any key in the `config` object that is NOT
170
+ * defined in the class's `static config` hierarchy is considered a public field or a
171
+ * dynamic property. These are assigned directly to the instance (`this.myField = value`)
172
+ * at the very beginning. This is crucial so that subsequent config hooks (like `afterSet*`)
173
+ * can access their latest values.
174
+ *
175
+ * 2. **Reactive Configs:** A property is considered reactive if it is defined with a trailing
176
+ * underscore (e.g., `myValue_`) in the `static config` of **any class in the inheritance
177
+ * chain**. Subclasses can provide new default values for these configs without the
178
+ * underscore, and they will still be reactive. Their values are applied via generated
179
+ * setters, triggering `beforeSet*` and `afterSet*` hooks, and they are wrapped in a
180
+ * `Neo.core.Config` instance to enable subscription-based reactivity.
181
+ *
182
+ * 3. **Non-Reactive Configs:** Properties defined in `static config` without a trailing
183
+ * underscore in their entire inheritance chain. Their default values are applied directly
184
+ * to the class **prototype**, making them shared across all instances and allowing for
185
+ * run-time modifications (prototypal inheritance). When a new value is passed to this
186
+ * method, it creates an instance-specific property that shadows the prototype value.
187
+ *
188
+ * This method also initializes the observable mixin (if applicable) and schedules asynchronous
189
+ * logic like `initAsync()` (which handles remote method access) to run after the synchronous
190
+ * construction chain is complete.
191
+ *
192
+ * @param {Object} config={} The initial configuration object for the instance.
138
193
  */
139
194
  construct(config={}) {
140
195
  let me = this;
@@ -152,13 +207,9 @@ class Base {
152
207
  }
153
208
  });
154
209
 
155
- me.createId(config.id || me.id);
210
+ me.id = config.id || IdGenerator.getId(this.getIdKey());
156
211
  delete config.id;
157
212
 
158
- if (me.constructor.config) {
159
- delete me.constructor.config.id
160
- }
161
-
162
213
  me.getStaticConfig('observable') && me.initObservable(config);
163
214
 
164
215
  // assign class field values prior to configs
@@ -202,7 +253,7 @@ class Base {
202
253
  if (oldValue) {
203
254
  if (hasManager) {
204
255
  Neo.manager.Instance.unregister(oldValue)
205
- } else {
256
+ } else if (Neo.idMap) {
206
257
  delete Neo.idMap[oldValue]
207
258
  }
208
259
  }
@@ -212,7 +263,7 @@ class Base {
212
263
  Neo.manager.Instance.register(me);
213
264
  } else {
214
265
  Neo.idMap ??= {};
215
- Neo.idMap[me.id] = me
266
+ Neo.idMap[value] = me
216
267
  }
217
268
  }
218
269
  }
@@ -342,16 +393,6 @@ class Base {
342
393
  this.__proto__.constructor.overwrittenMethods[methodName].call(this, ...args)
343
394
  }
344
395
 
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
396
  /**
356
397
  * Unregisters this instance from Neo.manager.Instance
357
398
  * and removes all object entries from this instance
@@ -359,10 +400,16 @@ class Base {
359
400
  destroy() {
360
401
  let me = this;
361
402
 
403
+ me.isDestroying = true;
404
+
362
405
  me.#timeoutIds.forEach(id => {
363
406
  clearTimeout(id)
364
407
  });
365
408
 
409
+ me.#configSubscriptionCleanups.forEach(cleanup => {
410
+ cleanup()
411
+ });
412
+
366
413
  if (Base.instanceManagerAvailable === true) {
367
414
  Neo.manager.Instance.unregister(me)
368
415
  } else if (Neo.idMap) {
@@ -386,6 +433,22 @@ class Base {
386
433
  me.isDestroyed = true
387
434
  }
388
435
 
436
+ /**
437
+ * A public method to access the underlying Config controller.
438
+ * This enables advanced interactions like subscriptions.
439
+ * @param {String} key The name of the config property (e.g., 'items').
440
+ * @returns {Config|undefined} The Config instance, or undefined if not found.
441
+ */
442
+ getConfig(key) {
443
+ let me = this;
444
+
445
+ if (!me.#configs[key] && me.isConfig(key)) {
446
+ me.#configs[key] = new Config(me.constructor.configDescriptors?.[key])
447
+ }
448
+
449
+ return me.#configs[key]
450
+ }
451
+
389
452
  /**
390
453
  * Used inside createId() as the default value passed to the IdGenerator.
391
454
  * Override this method as needed.
@@ -443,8 +506,9 @@ class Base {
443
506
 
444
507
  me.isConfiguring = true;
445
508
  Object.assign(me[configSymbol], me.mergeConfig(config, preventOriginalConfig));
509
+ delete me[configSymbol].id;
446
510
  me.processConfigs();
447
- me.isConfiguring = false;
511
+ me.isConfiguring = false
448
512
  }
449
513
 
450
514
  /**
@@ -475,6 +539,19 @@ class Base {
475
539
  return !this.isDestroyed
476
540
  }
477
541
 
542
+ /**
543
+ * @param {String} key
544
+ * @returns {Boolean}
545
+ */
546
+ isConfig(key) {
547
+ let me = this;
548
+ // If a `core.Config` controller is already created, return true (fastest possible check).
549
+ // If not, a config is considered "reactive" if it has a generated property setter
550
+ // AND it is present as a defined config in the merged static config hierarchy.
551
+ // Neo.setupClass() removes the underscore from the static config keys.
552
+ return me.#configs[key] || (Neo.hasPropertySetter(me, key) && (key in me.constructor.config))
553
+ }
554
+
478
555
  /**
479
556
  * Override this method to change the order configs are applied to this instance.
480
557
  * @param {Object} config
@@ -484,7 +561,8 @@ class Base {
484
561
  */
485
562
  mergeConfig(config, preventOriginalConfig) {
486
563
  let me = this,
487
- ctor = me.constructor;
564
+ ctor = me.constructor,
565
+ configDescriptors, staticConfig;
488
566
 
489
567
  if (!ctor.config) {
490
568
  throw new Error('Neo.applyClassConfig has not been run on ' + me.className)
@@ -494,7 +572,70 @@ class Base {
494
572
  me.originalConfig = Neo.clone(config, true, true)
495
573
  }
496
574
 
497
- return {...ctor.config, ...config}
575
+ configDescriptors = ctor.configDescriptors;
576
+ staticConfig = ctor.config;
577
+
578
+ if (configDescriptors) {
579
+ Object.entries(config).forEach(([key, instanceValue]) => {
580
+ const descriptor = configDescriptors[key];
581
+
582
+ if (descriptor?.merge) {
583
+ config[key] = Neo.mergeConfig(staticConfig[key], instanceValue, descriptor.merge)
584
+ }
585
+ })
586
+ }
587
+
588
+ return {...staticConfig, ...config}
589
+ }
590
+
591
+ /**
592
+ * Subscribes *this* instance (the subscriber) to changes of a specific config property on another instance (the publisher).
593
+ * Ensures automatic cleanup when *this* instance (the subscriber) is destroyed.
594
+ *
595
+ * @param {String|Neo.core.Base} publisher - The ID of the publisher instance or the instance reference itself.
596
+ * @param {String} configName - The name of the config property on the publisher to subscribe to (e.g., 'myConfig').
597
+ * @param {Function} fn - The callback function to execute when the config changes.
598
+ * @returns {Function} A cleanup function to manually unsubscribe if needed before this instance's destruction.
599
+ *
600
+ * @example
601
+ * // Subscribing to a config on another instance
602
+ * this.observeConfig(someOtherInstance, 'myConfig', (newValue, oldValue) => {
603
+ * console.log('myConfig changed:', newValue);
604
+ * });
605
+ *
606
+ * // Discouraged: Self-observation. Use afterSet<ConfigName>() hooks instead.
607
+ * this.observeConfig(this, 'myOwnConfig', (newValue, oldValue) => {
608
+ * console.log('myOwnConfig changed:', newValue);
609
+ * });
610
+ */
611
+ observeConfig(publisher, configName, fn) {
612
+ let publisherInstance = publisher;
613
+
614
+ if (Neo.isString(publisher)) {
615
+ publisherInstance = Neo.get(publisher);
616
+ if (!publisherInstance) {
617
+ console.warn(`Publisher instance with ID '${publisher}' not found. Cannot subscribe.`);
618
+ return Neo.emptyFn
619
+ }
620
+ }
621
+
622
+ if (!(publisherInstance instanceof Neo.core.Base)) {
623
+ console.warn(`Invalid publisher provided. Must be a Neo.core.Base instance or its ID.`);
624
+ return Neo.emptyFn
625
+ }
626
+
627
+ const configController = publisherInstance.getConfig(configName);
628
+
629
+ if (!configController) {
630
+ console.warn(`Config '${configName}' not found on publisher instance ${publisherInstance.id}. Cannot subscribe.`);
631
+ return Neo.emptyFn
632
+ }
633
+
634
+ const cleanup = configController.subscribe({id: this.id, fn});
635
+
636
+ this.#configSubscriptionCleanups.push(cleanup);
637
+
638
+ return cleanup
498
639
  }
499
640
 
500
641
  /**
@@ -615,24 +756,58 @@ class Base {
615
756
  }
616
757
 
617
758
  /**
618
- * Change multiple configs at once, ensuring that all afterSet methods get all new assigned values
759
+ * set() accepts the following input as keys:
760
+ * 1. Non-reactive configs
761
+ * 2. Reactive configs
762
+ * 3. Class fields defined via value
763
+ * 4. Class fields defined via get() & set()
764
+ * 5. "Anything else" will get directly get assigned to the instance
765
+ *
766
+ * The logic resolves circular dependencies as good as possible and ensures that config related hooks:
767
+ * - beforeGet<Config>
768
+ * - beforeSet<Config>
769
+ * - afterSet<Config>
770
+ * can access all new values from the batch operation.
619
771
  * @param {Object} values={}
620
772
  */
621
773
  set(values={}) {
622
- let me = this;
774
+ let me = this,
775
+ classFieldsViaSet = {};
776
+
777
+ // Start batching for effects
778
+ EffectBatchManager.startBatch();
623
779
 
624
780
  values = me.setFields(values);
625
781
 
626
782
  // If the initial config processing is still running,
627
783
  // finish this one first before dropping new values into the configSymbol.
628
- // see: https://github.com/neomjs/neo/issues/2201
784
+ // See: https://github.com/neomjs/neo/issues/2201
629
785
  if (me[forceAssignConfigs] !== true && Object.keys(me[configSymbol]).length > 0) {
630
786
  me.processConfigs()
631
787
  }
632
788
 
789
+ // Store class fields which are defined via get() & set() and ensure they won't get added to the config symbol.
790
+ Object.entries(values).forEach(([key, value]) => {
791
+ if (!me.isConfig(key)) {
792
+ classFieldsViaSet[key] = value;
793
+ delete values[key]
794
+ }
795
+ })
796
+
797
+ // Add reactive configs to the configSymbol
633
798
  Object.assign(me[configSymbol], values);
634
799
 
635
- me.processConfigs(true)
800
+ // Process class fields which are defined via get() & set() => now they can access the latest values
801
+ // for reactive and non-reactive configs, as well as class fields defined with values.
802
+ Object.entries(classFieldsViaSet).forEach(([key, value]) => {
803
+ me[key] = value
804
+ })
805
+
806
+ // Process reactive configs
807
+ me.processConfigs(true);
808
+
809
+ // End batching for effects
810
+ EffectBatchManager.endBatch();
636
811
  }
637
812
 
638
813
  /**
@@ -643,15 +818,14 @@ class Base {
643
818
  * @protected
644
819
  */
645
820
  setFields(config) {
646
- let me = this,
647
- configNames = me.constructor.config;
821
+ let me = this;
648
822
 
649
823
  Object.entries(config).forEach(([key, value]) => {
650
- if (!configNames.hasOwnProperty(key) && !Neo.hasPropertySetter(me, key)) {
824
+ if (!me.isConfig(key) && !Neo.hasPropertySetter(me, key)) {
651
825
  me[key] = value;
652
826
  delete config[key]
653
827
  }
654
- })
828
+ });
655
829
 
656
830
  return config
657
831
  }
@@ -693,7 +867,7 @@ class Base {
693
867
  * @returns {String}
694
868
  */
695
869
  get [Symbol.toStringTag]() {
696
- return `${this.className} (id: ${this.id})`
870
+ return this.className
697
871
  }
698
872
 
699
873
  /**
@@ -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,230 @@
1
+ import EffectManager from './EffectManager.mjs';
2
+ import {isDescriptor} from './ConfigSymbols.mjs';
3
+
4
+ /**
5
+ * Represents an observable container for a config property.
6
+ * This class manages the value of a config, its subscribers, and custom behaviors
7
+ * like merge strategies and equality checks defined via a descriptor object.
8
+ *
9
+ * The primary purpose of this class is to enable fine-grained reactivity and
10
+ * decoupled cross-instance state sharing within the Neo.mjs framework.
11
+ * @class Neo.core.Config
12
+ * @private
13
+ * @internal
14
+ */
15
+ class Config {
16
+ /**
17
+ * A Set to store callback functions that subscribe to changes in this config's value.
18
+ * @private
19
+ */
20
+ #subscribers = {}
21
+ /**
22
+ * The internal value of the config property.
23
+ * @member #value
24
+ * @private
25
+ */
26
+ #value
27
+ /**
28
+ * The cloning strategy to use when setting a new value.
29
+ * Supported values: 'deep', 'shallow', 'none'.
30
+ * @member {String} clone='deep'
31
+ */
32
+
33
+ /**
34
+ * The cloning strategy to use when getting a value.
35
+ * Supported values: 'deep', 'shallow', 'none'.
36
+ * @member {String} cloneOnGet=null
37
+ */
38
+
39
+ /**
40
+ * The function used to compare new and old values for equality.
41
+ * Defaults to `Neo.isEqual`. Can be overridden via a descriptor.
42
+ * @member {Function} isEqual=Neo.isEqual
43
+ */
44
+
45
+ /**
46
+ * The strategy to use when merging new values into this config.
47
+ * Defaults to 'replace'. Can be overridden via a descriptor merge property.
48
+ * @member {Function|String} mergeStrategy='replace'
49
+ */
50
+
51
+ /**
52
+ * Creates an instance of Config.
53
+ * @param {any|Object} configObject - The initial value for the config.
54
+ */
55
+ constructor(configObject) {
56
+ if (Neo.isObject(configObject) && configObject[isDescriptor] === true) {
57
+ this.initDescriptor(configObject)
58
+ } else {
59
+ this.#value = configObject
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Gets the current value of the config property.
65
+ * @returns {any} The current value.
66
+ */
67
+ get() {
68
+ // Registers this Config instance as a dependency with the currently active Effect,
69
+ // enabling automatic re-execution when this Config's value changes.
70
+ EffectManager.getActiveEffect()?.addDependency(this);
71
+ return this.#value
72
+ }
73
+
74
+ /**
75
+ * Initializes the `Config` instance using a descriptor object.
76
+ * Extracts `clone`, `mergeStrategy` and `isEqual` from the descriptor.
77
+ * The internal `#value` is NOT set by this method.
78
+ * @param {Object} descriptor - The descriptor object for the config.
79
+ * @param {any} descriptor.value - The default value for the config (not set by this method).
80
+ * @param {string} [descriptor.clone='deep'] - The clone strategy for set.
81
+ * @param {string} [descriptor.cloneOnGet] - The clone strategy for get. Defaults to 'shallow' if clone is 'deep' or 'shallow', and 'none' if clone is 'none'.
82
+ * @param {string} [descriptor.merge='deep'] - The merge strategy.
83
+ * @param {Function} [descriptor.isEqual=Neo.isEqual] - The equality comparison function.
84
+ */
85
+ initDescriptor({clone, cloneOnGet, isEqual, merge}) {
86
+ let me = this;
87
+
88
+ if (clone && clone !== me.clone) {
89
+ Object.defineProperty(me, 'clone', {
90
+ value: clone, writable: true, configurable: true, enumerable: true
91
+ })
92
+ }
93
+
94
+ if (cloneOnGet && cloneOnGet !== me.cloneOnGet) {
95
+ Object.defineProperty(me, 'cloneOnGet', {
96
+ value: cloneOnGet, writable: true, configurable: true, enumerable: true
97
+ })
98
+ }
99
+
100
+ if (isEqual && isEqual !== me.isEqual) {
101
+ Object.defineProperty(me, 'isEqual', {
102
+ value: isEqual, writable: true, configurable: true, enumerable: true
103
+ })
104
+ }
105
+
106
+ if (merge && merge !== me.mergeStrategy) {
107
+ Object.defineProperty(me, 'mergeStrategy', {
108
+ value: merge, writable: true, configurable: true, enumerable: true
109
+ })
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Notifies all subscribed callbacks about a change in the config's value.
115
+ * @param {any} newValue - The new value of the config.
116
+ * @param {any} oldValue - The old value of the config.
117
+ */
118
+ notify(newValue, oldValue) {
119
+ for (const id in this.#subscribers) {
120
+ if (this.#subscribers.hasOwnProperty(id)) {
121
+ const subscriberSet = this.#subscribers[id];
122
+ for (const callback of subscriberSet) {
123
+ callback(newValue, oldValue)
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Sets a new value for the config property.
131
+ * This method performs an equality check using `this.isEqual` before updating the value.
132
+ * If the value has changed, it updates `#value` and notifies all subscribers.
133
+ * @param {any} newValue - The new value to set.
134
+ * @returns {Boolean} True if the value changed, false otherwise.
135
+ */
136
+ set(newValue) {
137
+ if (newValue === undefined) return false; // Preserve original behavior for undefined
138
+
139
+ const
140
+ me = this,
141
+ oldValue = me.#value;
142
+
143
+ // The setter automatically uses the configured equality check
144
+ if (!me.isEqual(newValue, oldValue)) {
145
+ me.#value = newValue;
146
+ me.notify(newValue, oldValue);
147
+ return true
148
+ }
149
+
150
+ return false
151
+ }
152
+
153
+ /**
154
+ * Sets the internal value of the config property directly, without performing
155
+ * an equality check or notifying subscribers.
156
+ * This method is intended for internal framework use where direct assignment
157
+ * is necessary (e.g., during initial setup or specific internal optimizations).
158
+ * @param {any} newValue - The new value to set directly.
159
+ */
160
+ setRaw(newValue) {
161
+ this.#value = newValue
162
+ }
163
+
164
+ /**
165
+ * Subscribes a callback function to changes in this config's value.
166
+ * The callback will be invoked with `(newValue, oldValue)` whenever the config changes.
167
+ * @param {Object} options - An object containing the subscription details.
168
+ * @param {String} options.id - The ID of the subscription owner (e.g., a Neo.core.Base instance's id).
169
+ * @param {Function} options.fn - The callback function.
170
+ * @returns {Function} A cleanup function to unsubscribe the callback.
171
+ */
172
+ subscribe({id, fn}) {
173
+ if (typeof id !== 'string' || id.length === 0 || typeof fn !== 'function') {
174
+ throw new Error([
175
+ 'Config.subscribe: options must be an object with a non-empty string `id` ',
176
+ '(the subscription owner\'s id), and a callback function `fn`.'
177
+ ].join(''))
178
+ }
179
+
180
+ const me = this;
181
+
182
+ if (!me.#subscribers[id]) {
183
+ me.#subscribers[id] = new Set()
184
+ }
185
+
186
+ me.#subscribers[id].add(fn);
187
+
188
+ return () => {
189
+ const subscriberSet = me.#subscribers[id];
190
+ if (subscriberSet) {
191
+ subscriberSet.delete(fn);
192
+ if (subscriberSet.size === 0) {
193
+ delete me.#subscribers[id]
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ Object.defineProperties(Config.prototype, {
201
+ clone: {
202
+ value: 'deep',
203
+ writable: false,
204
+ configurable: true,
205
+ enumerable: false
206
+ },
207
+ cloneOnGet: {
208
+ value: null,
209
+ writable: false,
210
+ configurable: true,
211
+ enumerable: false
212
+ },
213
+ isEqual: {
214
+ value: Neo.isEqual,
215
+ writable: false,
216
+ configurable: true,
217
+ enumerable: false
218
+ },
219
+ mergeStrategy: {
220
+ value: 'replace',
221
+ writable: false,
222
+ configurable: true,
223
+ enumerable: false
224
+ }
225
+ });
226
+
227
+ const ns = Neo.ns('Neo.core', true);
228
+ ns.Config = Config;
229
+
230
+ 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');