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,12 +1,13 @@
1
- import Component from '../component/Base.mjs';
2
- import LayoutBase from '../layout/Base.mjs';
3
- import LayoutCard from '../layout/Card.mjs';
4
- import LayoutFit from '../layout/Fit.mjs';
5
- import LayoutGrid from '../layout/Grid.mjs';
6
- import LayoutHbox from '../layout/HBox.mjs';
7
- import LayoutVBox from '../layout/VBox.mjs';
8
- import Logger from '../util/Logger.mjs';
9
- import NeoArray from '../util/Array.mjs';
1
+ import Component from '../component/Base.mjs';
2
+ import LayoutBase from '../layout/Base.mjs';
3
+ import LayoutCard from '../layout/Card.mjs';
4
+ import LayoutFit from '../layout/Fit.mjs';
5
+ import LayoutGrid from '../layout/Grid.mjs';
6
+ import LayoutHbox from '../layout/HBox.mjs';
7
+ import LayoutVBox from '../layout/VBox.mjs';
8
+ import Logger from '../util/Logger.mjs';
9
+ import NeoArray from '../util/Array.mjs';
10
+ import {isDescriptor} from '../core/ConfigSymbols.mjs';
10
11
 
11
12
  const byWeight = ({ weight : lhs = 0 }, { weight : rhs = 0 }) => lhs - rhs;
12
13
 
@@ -31,9 +32,15 @@ class Container extends Component {
31
32
  */
32
33
  baseCls: ['neo-container'],
33
34
  /**
34
- * @member {Object} itemDefaults_=null
35
+ * Default configuration for child items within this container.
36
+ * This config uses a descriptor to enable deep merging with instance based itemDefaults.
37
+ * @member {Object} itemDefaults_={[isDescriptor]: true, merge: 'deep', value: null}
35
38
  */
36
- itemDefaults_: null,
39
+ itemDefaults_: {
40
+ [isDescriptor]: true,
41
+ merge : 'deep',
42
+ value : null
43
+ },
37
44
  /**
38
45
  * An array or an object of config objects|instances|modules for each child component
39
46
  * @member {Object[]} items_=[]
@@ -85,7 +92,13 @@ class Container extends Component {
85
92
  * ]
86
93
  * });
87
94
  */
88
- items_: [],
95
+ items_: {
96
+ [isDescriptor]: true,
97
+ clone : 'shallow',
98
+ cloneOnGet : 'none',
99
+ isEqual : () => false,
100
+ value : []
101
+ },
89
102
  /**
90
103
  * It is crucial to define a layout before the container does get rendered.
91
104
  * Meaning: onConstructed() is the latest life-cycle point.
@@ -342,10 +355,7 @@ class Container extends Component {
342
355
  }
343
356
 
344
357
  item.set(config);
345
-
346
- // In case an item got created outside a stateProvider based hierarchy, there might be bindings or string
347
- // based listeners which still need to get resolved.
348
- item.getStateProvider()?.parseConfig(item);
358
+ item.getStateProvider()?.createBindings(item);
349
359
  break
350
360
  }
351
361
 
@@ -617,14 +627,8 @@ class Container extends Component {
617
627
  config = super.mergeConfig(...args),
618
628
  ctorItems;
619
629
 
620
- // avoid any interference on prototype level
621
- // does not clone existing Neo instances
622
-
623
- if (config.itemDefaults) {
624
- me._itemDefaults = Neo.clone(config.itemDefaults, true, true);
625
- delete config.itemDefaults
626
- }
627
-
630
+ // Avoid any interference on prototype level
631
+ // Does not clone existing Neo instances
628
632
  if (config.items) {
629
633
  ctorItems = me.constructor.config.items;
630
634
 
package/src/core/Base.mjs CHANGED
@@ -3,7 +3,8 @@ import Compare from '../core/Co
3
3
  import Util from '../core/Util.mjs';
4
4
  import Config from './Config.mjs';
5
5
  import {isDescriptor} from './ConfigSymbols.mjs';
6
- import IdGenerator from './IdGenerator.mjs'
6
+ import IdGenerator from './IdGenerator.mjs';
7
+ import EffectBatchManager from './EffectBatchManager.mjs';
7
8
 
8
9
  const configSymbol = Symbol.for('configSymbol'),
9
10
  forceAssignConfigs = Symbol('forceAssignConfigs'),
@@ -98,12 +99,19 @@ class Base {
98
99
  * @protected
99
100
  */
100
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,
101
109
  /**
102
110
  * The config will get set to `true` once the Promise of `async initAsync()` is resolved.
103
111
  * You can use `afterSetIsReady()` to get notified once the ready state is reached.
104
112
  * Since not all classes use the Observable mixin, Neo will not fire an event.
105
113
  * method body.
106
- * @member {Boolean} isReady=false
114
+ * @member {Boolean} isReady_=false
107
115
  */
108
116
  isReady_: false,
109
117
  /**
@@ -135,6 +143,12 @@ class Base {
135
143
  * @private
136
144
  */
137
145
  #configs = {};
146
+ /**
147
+ * Internal cache for all config subscription cleanup functions.
148
+ * @member {Function[]} #configSubscriptionCleanups=[]
149
+ * @private
150
+ */
151
+ #configSubscriptionCleanups = []
138
152
  /**
139
153
  * Internal cache for all timeout ids when using this.timeout()
140
154
  * @member {Number[]} timeoutIds=[]
@@ -239,7 +253,7 @@ class Base {
239
253
  if (oldValue) {
240
254
  if (hasManager) {
241
255
  Neo.manager.Instance.unregister(oldValue)
242
- } else {
256
+ } else if (Neo.idMap) {
243
257
  delete Neo.idMap[oldValue]
244
258
  }
245
259
  }
@@ -249,7 +263,7 @@ class Base {
249
263
  Neo.manager.Instance.register(me);
250
264
  } else {
251
265
  Neo.idMap ??= {};
252
- Neo.idMap[me.id] = me
266
+ Neo.idMap[value] = me
253
267
  }
254
268
  }
255
269
  }
@@ -386,10 +400,16 @@ class Base {
386
400
  destroy() {
387
401
  let me = this;
388
402
 
403
+ me.isDestroying = true;
404
+
389
405
  me.#timeoutIds.forEach(id => {
390
406
  clearTimeout(id)
391
407
  });
392
408
 
409
+ me.#configSubscriptionCleanups.forEach(cleanup => {
410
+ cleanup()
411
+ });
412
+
393
413
  if (Base.instanceManagerAvailable === true) {
394
414
  Neo.manager.Instance.unregister(me)
395
415
  } else if (Neo.idMap) {
@@ -423,7 +443,7 @@ class Base {
423
443
  let me = this;
424
444
 
425
445
  if (!me.#configs[key] && me.isConfig(key)) {
426
- me.#configs[key] = new Config()
446
+ me.#configs[key] = new Config(me.constructor.configDescriptors?.[key])
427
447
  }
428
448
 
429
449
  return me.#configs[key]
@@ -488,7 +508,7 @@ class Base {
488
508
  Object.assign(me[configSymbol], me.mergeConfig(config, preventOriginalConfig));
489
509
  delete me[configSymbol].id;
490
510
  me.processConfigs();
491
- me.isConfiguring = false;
511
+ me.isConfiguring = false
492
512
  }
493
513
 
494
514
  /**
@@ -524,10 +544,12 @@ class Base {
524
544
  * @returns {Boolean}
525
545
  */
526
546
  isConfig(key) {
527
- // A config is considered "reactive" if it has a generated property setter
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
528
550
  // AND it is present as a defined config in the merged static config hierarchy.
529
551
  // Neo.setupClass() removes the underscore from the static config keys.
530
- return Neo.hasPropertySetter(this, key) && (key in this.constructor.config);
552
+ return me.#configs[key] || (Neo.hasPropertySetter(me, key) && (key in me.constructor.config))
531
553
  }
532
554
 
533
555
  /**
@@ -539,7 +561,8 @@ class Base {
539
561
  */
540
562
  mergeConfig(config, preventOriginalConfig) {
541
563
  let me = this,
542
- ctor = me.constructor;
564
+ ctor = me.constructor,
565
+ configDescriptors, staticConfig;
543
566
 
544
567
  if (!ctor.config) {
545
568
  throw new Error('Neo.applyClassConfig has not been run on ' + me.className)
@@ -549,7 +572,70 @@ class Base {
549
572
  me.originalConfig = Neo.clone(config, true, true)
550
573
  }
551
574
 
552
- 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
553
639
  }
554
640
 
555
641
  /**
@@ -670,24 +756,58 @@ class Base {
670
756
  }
671
757
 
672
758
  /**
673
- * 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.
674
771
  * @param {Object} values={}
675
772
  */
676
773
  set(values={}) {
677
- let me = this;
774
+ let me = this,
775
+ classFieldsViaSet = {};
776
+
777
+ // Start batching for effects
778
+ EffectBatchManager.startBatch();
678
779
 
679
780
  values = me.setFields(values);
680
781
 
681
782
  // If the initial config processing is still running,
682
783
  // finish this one first before dropping new values into the configSymbol.
683
- // see: https://github.com/neomjs/neo/issues/2201
784
+ // See: https://github.com/neomjs/neo/issues/2201
684
785
  if (me[forceAssignConfigs] !== true && Object.keys(me[configSymbol]).length > 0) {
685
786
  me.processConfigs()
686
787
  }
687
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
688
798
  Object.assign(me[configSymbol], values);
689
799
 
690
- 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();
691
811
  }
692
812
 
693
813
  /**
@@ -698,15 +818,14 @@ class Base {
698
818
  * @protected
699
819
  */
700
820
  setFields(config) {
701
- let me = this,
702
- configNames = me.constructor.config;
821
+ let me = this;
703
822
 
704
823
  Object.entries(config).forEach(([key, value]) => {
705
- if (!configNames.hasOwnProperty(key) && !Neo.hasPropertySetter(me, key)) {
824
+ if (!me.isConfig(key) && !Neo.hasPropertySetter(me, key)) {
706
825
  me[key] = value;
707
826
  delete config[key]
708
827
  }
709
- })
828
+ });
710
829
 
711
830
  return config
712
831
  }
@@ -748,7 +867,7 @@ class Base {
748
867
  * @returns {String}
749
868
  */
750
869
  get [Symbol.toStringTag]() {
751
- return `${this.className} (id: ${this.id})`
870
+ return this.className
752
871
  }
753
872
 
754
873
  /**
@@ -1,45 +1,52 @@
1
+ import EffectManager from './EffectManager.mjs';
1
2
  import {isDescriptor} from './ConfigSymbols.mjs';
2
3
 
3
4
  /**
4
- * @src/util/ClassSystem.mjs Neo.core.Config
5
- * @private
6
- * @internal
7
- *
8
5
  * Represents an observable container for a config property.
9
6
  * This class manages the value of a config, its subscribers, and custom behaviors
10
7
  * like merge strategies and equality checks defined via a descriptor object.
11
8
  *
12
9
  * The primary purpose of this class is to enable fine-grained reactivity and
13
10
  * decoupled cross-instance state sharing within the Neo.mjs framework.
11
+ * @class Neo.core.Config
12
+ * @private
13
+ * @internal
14
14
  */
15
15
  class Config {
16
16
  /**
17
- * The internal value of the config property.
17
+ * A Set to store callback functions that subscribe to changes in this config's value.
18
18
  * @private
19
- * @apps/portal/view/about/MemberContainer.mjs {any} #value
20
19
  */
21
- #value;
22
-
20
+ #subscribers = {}
23
21
  /**
24
- * A Set to store callback functions that subscribe to changes in this config's value.
22
+ * The internal value of the config property.
23
+ * @member #value
25
24
  * @private
26
- * @apps/portal/view/about/MemberContainer.mjs {Set<Function>} #subscribers
27
25
  */
28
- #subscribers = new Set();
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
+ */
29
32
 
30
33
  /**
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
+ * The cloning strategy to use when getting a value.
35
+ * Supported values: 'deep', 'shallow', 'none'.
36
+ * @member {String} cloneOnGet=null
34
37
  */
35
- mergeStrategy = 'deep';
36
38
 
37
39
  /**
38
40
  * The function used to compare new and old values for equality.
39
41
  * Defaults to `Neo.isEqual`. Can be overridden via a descriptor.
40
- * @apps/portal/view/about/MemberContainer.mjs {Function} isEqual
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'
41
49
  */
42
- isEqual = Neo.isEqual;
43
50
 
44
51
  /**
45
52
  * Creates an instance of Config.
@@ -58,24 +65,49 @@ class Config {
58
65
  * @returns {any} The current value.
59
66
  */
60
67
  get() {
61
- return this.#value;
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
62
72
  }
63
73
 
64
74
  /**
65
75
  * Initializes the `Config` instance using a descriptor object.
66
- * Extracts `mergeStrategy` and `isEqual` from the descriptor.
76
+ * Extracts `clone`, `mergeStrategy` and `isEqual` from the descriptor.
67
77
  * 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.
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.
71
83
  * @param {Function} [descriptor.isEqual=Neo.isEqual] - The equality comparison function.
72
84
  */
73
- initDescriptor({isEqual, merge, value}) {
85
+ initDescriptor({clone, cloneOnGet, isEqual, merge}) {
74
86
  let me = this;
75
87
 
76
- me.#value = value
77
- me.mergeStrategy = merge || me.mergeStrategy;
78
- me.isEqual = isEqual || me.isEqual;
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
+ }
79
111
  }
80
112
 
81
113
  /**
@@ -84,8 +116,13 @@ class Config {
84
116
  * @param {any} oldValue - The old value of the config.
85
117
  */
86
118
  notify(newValue, oldValue) {
87
- for (const callback of this.#subscribers) {
88
- callback(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
+ }
89
126
  }
90
127
  }
91
128
 
@@ -127,13 +164,67 @@ class Config {
127
164
  /**
128
165
  * Subscribes a callback function to changes in this config's value.
129
166
  * 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.
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.
131
170
  * @returns {Function} A cleanup function to unsubscribe the callback.
132
171
  */
133
- subscribe(callback) {
134
- this.#subscribers.add(callback);
135
- return () => this.#subscribers.delete(callback)
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
+ }
136
197
  }
137
198
  }
138
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
+
139
230
  export default Config;