neo.mjs 10.0.0-beta.6 → 10.0.1

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 (52) hide show
  1. package/.github/RELEASE_NOTES/v10.0.0-beta.1.md +20 -0
  2. package/.github/RELEASE_NOTES/v10.0.0-beta.2.md +73 -0
  3. package/.github/RELEASE_NOTES/v10.0.0-beta.3.md +39 -0
  4. package/.github/RELEASE_NOTES/v10.0.0.md +52 -0
  5. package/ServiceWorker.mjs +2 -2
  6. package/apps/portal/index.html +1 -1
  7. package/apps/portal/resources/data/blog.json +24 -0
  8. package/apps/portal/view/ViewportController.mjs +6 -4
  9. package/apps/portal/view/examples/List.mjs +28 -19
  10. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  11. package/examples/functional/button/base/MainContainer.mjs +207 -0
  12. package/examples/functional/button/base/app.mjs +6 -0
  13. package/examples/functional/button/base/index.html +11 -0
  14. package/examples/functional/button/base/neo-config.json +6 -0
  15. package/learn/blog/v10-deep-dive-functional-components.md +293 -0
  16. package/learn/blog/v10-deep-dive-reactivity.md +522 -0
  17. package/learn/blog/v10-deep-dive-state-provider.md +432 -0
  18. package/learn/blog/v10-deep-dive-vdom-revolution.md +194 -0
  19. package/learn/blog/v10-post1-love-story.md +383 -0
  20. package/learn/guides/uibuildingblocks/WorkingWithVDom.md +26 -2
  21. package/package.json +3 -3
  22. package/src/DefaultConfig.mjs +2 -2
  23. package/src/Neo.mjs +47 -45
  24. package/src/component/Abstract.mjs +412 -0
  25. package/src/component/Base.mjs +18 -380
  26. package/src/core/Base.mjs +34 -33
  27. package/src/core/Effect.mjs +30 -34
  28. package/src/core/EffectManager.mjs +101 -14
  29. package/src/core/Observable.mjs +69 -65
  30. package/src/form/field/Text.mjs +11 -5
  31. package/src/functional/button/Base.mjs +384 -0
  32. package/src/functional/component/Base.mjs +51 -145
  33. package/src/layout/Cube.mjs +8 -4
  34. package/src/manager/VDomUpdate.mjs +179 -94
  35. package/src/mixin/VdomLifecycle.mjs +4 -1
  36. package/src/state/Provider.mjs +41 -27
  37. package/src/util/VDom.mjs +11 -4
  38. package/src/util/vdom/TreeBuilder.mjs +38 -62
  39. package/src/worker/mixin/RemoteMethodAccess.mjs +1 -6
  40. package/test/siesta/siesta.js +15 -3
  41. package/test/siesta/tests/VdomCalendar.mjs +7 -7
  42. package/test/siesta/tests/VdomHelper.mjs +7 -7
  43. package/test/siesta/tests/classic/Button.mjs +113 -0
  44. package/test/siesta/tests/core/EffectBatching.mjs +46 -41
  45. package/test/siesta/tests/functional/Button.mjs +113 -0
  46. package/test/siesta/tests/state/ProviderNestedDataConfigs.mjs +59 -0
  47. package/test/siesta/tests/vdom/Advanced.mjs +14 -8
  48. package/test/siesta/tests/vdom/VdomAsymmetricUpdates.mjs +9 -9
  49. package/test/siesta/tests/vdom/VdomRealWorldUpdates.mjs +6 -6
  50. package/test/siesta/tests/vdom/layout/Cube.mjs +11 -7
  51. package/test/siesta/tests/vdom/table/Container.mjs +9 -5
  52. package/src/core/EffectBatchManager.mjs +0 -67
@@ -1,7 +1,6 @@
1
- import Config from './Config.mjs';
2
- import EffectManager from './EffectManager.mjs';
3
- import EffectBatchManager from './EffectBatchManager.mjs';
4
- import IdGenerator from './IdGenerator.mjs';
1
+ import Config from './Config.mjs';
2
+ import EffectManager from './EffectManager.mjs';
3
+ import IdGenerator from './IdGenerator.mjs';
5
4
 
6
5
  /**
7
6
  * Creates a reactive effect that automatically tracks its dependencies and re-runs when any of them change.
@@ -73,8 +72,6 @@ class Effect {
73
72
  constructor(fn, options={}) {
74
73
  const me = this;
75
74
 
76
- // This single statement handles both (fn, options) and ({...}) signatures
77
- // by normalizing them into a single object that we can destructure.
78
75
  const {
79
76
  fn: effectFn,
80
77
  componentId,
@@ -88,16 +85,10 @@ class Effect {
88
85
 
89
86
  me.isRunning = new Config(false);
90
87
 
91
- // The subscriber(s) must be added *before* the first run is triggered.
92
- // This is critical for consumers like functional components, which need to process
93
- // the initial VDOM synchronously within the constructor lifecycle.
94
88
  if (subscriber) {
95
- // A concise way to handle both single and array subscribers.
96
89
  [].concat(subscriber).forEach(sub => me.isRunning.subscribe(sub))
97
90
  }
98
91
 
99
- // If lazy, just store the function without running it.
100
- // Otherwise, use the setter to trigger the initial run.
101
92
  if (lazy) {
102
93
  me._fn = effectFn
103
94
  } else {
@@ -117,41 +108,53 @@ class Effect {
117
108
  }
118
109
 
119
110
  /**
120
- * Executes the effect function, tracking its dependencies.
121
- * This is called automatically on creation and whenever a dependency changes.
122
- * The dynamic re-tracking ensures the effect always reflects its current dependencies,
123
- * even if the logic within `fn` changes conditionally.
111
+ * Executes the effect function, re-evaluating its dependencies.
112
+ * If the EffectManager is paused (e.g., inside a batch), it queues itself to be run later.
124
113
  * @protected
125
114
  */
126
115
  run() {
127
116
  const me = this;
128
117
 
129
- EffectManager.pause(); // Pause dependency tracking for isRunning.get()
130
- if (me.isDestroyed || me.isRunning.get()) {
131
- EffectManager.resume(); // Resume if we return early
118
+ if (me.isDestroyed) {
132
119
  return
133
120
  }
134
121
 
135
- if (EffectBatchManager.isBatchActive()) {
136
- EffectBatchManager.queueEffect(me);
122
+ // Check if already running without creating a dependency on `isRunning`.
123
+ EffectManager.pauseTracking();
124
+ const isRunning = me.isRunning.get();
125
+ EffectManager.resumeTracking();
126
+
127
+ if (isRunning) {
137
128
  return
138
129
  }
139
130
 
131
+ // If the manager is globally paused for batching, queue this effect and stop.
132
+ if (EffectManager.isPaused()) {
133
+ EffectManager.queue(me);
134
+ return
135
+ }
136
+
137
+ // Set `isRunning` to true without creating a dependency.
138
+ EffectManager.pauseTracking();
140
139
  me.isRunning.set(true);
140
+ EffectManager.resumeTracking();
141
141
 
142
+ // Clear old dependencies and set this as the active effect.
142
143
  me.dependencies.forEach(cleanup => cleanup());
143
144
  me.dependencies.clear();
144
-
145
145
  EffectManager.push(me);
146
- EffectManager.resume();
147
146
 
148
147
  try {
148
+ // Execute the function, which will collect new dependencies.
149
149
  me.fn()
150
150
  } finally {
151
+ // Clean up after the run.
151
152
  EffectManager.pop();
152
- EffectManager.pause(); // Pause dependency tracking for isRunning.set(false)
153
+
154
+ // Set `isRunning` to false without creating a dependency.
155
+ EffectManager.pauseTracking();
153
156
  me.isRunning.set(false);
154
- EffectManager.resume() // Resume after isRunning.set(false)
157
+ EffectManager.resumeTracking()
155
158
  }
156
159
  }
157
160
 
@@ -163,7 +166,6 @@ class Effect {
163
166
  addDependency(config) {
164
167
  const me = this;
165
168
 
166
- // Only add if not already a dependency. Map uses strict equality (===) for object keys.
167
169
  if (!me.dependencies.has(config)) {
168
170
  const cleanup = config.subscribe({
169
171
  id: me.id,
@@ -175,11 +177,7 @@ class Effect {
175
177
  }
176
178
  }
177
179
 
178
- Neo.core ??= {};
179
-
180
- if (!Neo.core.Effect) {
181
- Neo.core.Effect = Effect;
182
-
180
+ export default Neo.gatekeep(Effect, 'Neo.core.Effect', () => {
183
181
  /**
184
182
  * Factory shortcut to create a new Neo.core.Effect instance.
185
183
  * @function Neo.effect
@@ -188,6 +186,4 @@ if (!Neo.core.Effect) {
188
186
  * @returns {Neo.core.Effect}
189
187
  */
190
188
  Neo.effect = (fn, options) => new Effect(fn, options)
191
- }
192
-
193
- export default Neo.core.Effect;
189
+ });
@@ -1,20 +1,47 @@
1
1
  /**
2
- * A singleton manager to track the currently running effect.
3
- * This allows reactive properties to know which effect to subscribe to.
2
+ * A singleton manager to track the currently running effect and control global effect execution.
3
+ * It provides a centralized mechanism for pausing, resuming, and batching effect runs.
4
4
  * @class Neo.core.EffectManager
5
5
  * @singleton
6
6
  */
7
7
  const EffectManager = {
8
+ /**
9
+ * A stack to keep track of the currently active effect and its predecessors.
10
+ * @member {Neo.core.Effect[]} effectStack=[]
11
+ * @protected
12
+ */
8
13
  effectStack: [],
9
- isPaused : false,
14
+ /**
15
+ * A flag to temporarily disable dependency tracking for the active effect.
16
+ * This is used internally to prevent effects from depending on their own state, like `isRunning`.
17
+ * @member {Boolean} isTrackingPaused=false
18
+ * @protected
19
+ */
20
+ isTrackingPaused: false,
21
+ /**
22
+ * A counter to manage nested calls to pause() and resume(). Effect execution is
23
+ * paused or batched while this counter is greater than 0.
24
+ * @member {Number} pauseCounter=0
25
+ * @protected
26
+ */
27
+ pauseCounter: 0,
28
+ /**
29
+ * A Set to store unique effects that are triggered while the manager is paused.
30
+ * These effects will be run when resume() is called and the pauseCounter returns to 0.
31
+ * @member {Set<Neo.core.Effect>} queuedEffects=new Set()
32
+ * @protected
33
+ */
34
+ queuedEffects: new Set(),
10
35
 
11
36
  /**
12
- * Adds a `Neo.core.Config` instance as a dependency for the currently active effect.
13
- * This method checks if the EffectManager is paused before adding the dependency.
37
+ * Adds a `Neo.core.Config` instance as a dependency for the currently active effect,
38
+ * unless dependency tracking is explicitly paused.
14
39
  * @param {Neo.core.Config} config The config instance to add as a dependency.
15
40
  */
16
41
  addDependency(config) {
17
- !this.isPaused && this.getActiveEffect()?.addDependency(config)
42
+ if (!this.isTrackingPaused) {
43
+ this.getActiveEffect()?.addDependency(config)
44
+ }
18
45
  },
19
46
 
20
47
  /**
@@ -26,14 +53,31 @@ const EffectManager = {
26
53
  },
27
54
 
28
55
  /**
29
- * Pauses dependency tracking for effects. While paused, calls to `addDependency` will be ignored.
56
+ * Checks if effect execution is currently paused or batched.
57
+ * @returns {Boolean} True if the pauseCounter is greater than 0.
58
+ */
59
+ isPaused() {
60
+ return this.pauseCounter > 0
61
+ },
62
+
63
+ /**
64
+ * Pauses effect execution and begins batching.
65
+ * Each call to pause() increments a counter, allowing for nested control.
30
66
  */
31
67
  pause() {
32
- this.isPaused = true;
68
+ this.pauseCounter++
69
+ },
70
+
71
+ /**
72
+ * Disables dependency tracking for the currently active effect.
73
+ * @protected
74
+ */
75
+ pauseTracking() {
76
+ this.isTrackingPaused = true
33
77
  },
34
78
 
35
79
  /**
36
- * Pops the current effect from the stack, returning to the previous effect (if any).
80
+ * Pops the current effect from the stack.
37
81
  * @returns {Neo.core.Effect|null}
38
82
  */
39
83
  pop() {
@@ -41,7 +85,7 @@ const EffectManager = {
41
85
  },
42
86
 
43
87
  /**
44
- * Pushes an effect onto the stack, marking it as the currently running effect.
88
+ * Pushes an effect onto the stack.
45
89
  * @param {Neo.core.Effect} effect The effect to push.
46
90
  */
47
91
  push(effect) {
@@ -49,12 +93,55 @@ const EffectManager = {
49
93
  },
50
94
 
51
95
  /**
52
- * Resumes dependency tracking for effects.
96
+ * Queues a unique effect to be run later.
97
+ * @param {Neo.core.Effect} effect The effect to queue.
98
+ * @protected
99
+ */
100
+ queue(effect) {
101
+ this.queuedEffects.add(effect)
102
+ },
103
+
104
+ /**
105
+ * Resumes effect execution. If the pause counter returns to zero and effects
106
+ * have been queued, they will all be executed synchronously.
53
107
  */
54
108
  resume() {
55
- this.isPaused = false;
109
+ let me = this;
110
+
111
+ if (me.pauseCounter > 0) {
112
+ me.pauseCounter--;
113
+
114
+ if (me.pauseCounter === 0 && me.queuedEffects.size > 0) {
115
+ const effectsToRun = [...me.queuedEffects];
116
+ me.queuedEffects.clear();
117
+ effectsToRun.forEach(effect => effect.run())
118
+ }
119
+ }
120
+ },
121
+
122
+ /**
123
+ * Re-enables dependency tracking for the currently active effect.
124
+ * @protected
125
+ */
126
+ resumeTracking() {
127
+ this.isTrackingPaused = false
56
128
  }
57
129
  };
58
130
 
59
- export default Neo.gatekeep(EffectManager, 'Neo.core.EffectManager');
60
-
131
+ export default Neo.gatekeep(EffectManager, 'Neo.core.EffectManager', () => {
132
+ /**
133
+ * Wraps a function in a batch operation, ensuring that all effects triggered
134
+ * within it are run only once after the function completes.
135
+ * @function Neo.batch
136
+ * @param {Function} fn The function to execute.
137
+ */
138
+ Neo.batch = function(fn) {
139
+ EffectManager.pause();
140
+ try {
141
+ fn()
142
+ } finally {
143
+ // The public resume() method handles running queued effects.
144
+ EffectManager.resume()
145
+ }
146
+ }
147
+ });
@@ -1,7 +1,16 @@
1
1
  import Base from './Base.mjs';
2
2
  import NeoArray from '../util/Array.mjs';
3
+ import {isDescriptor} from '../core/ConfigSymbols.mjs';
3
4
  import {resolveCallback} from '../util/Function.mjs';
4
5
 
6
+ /**
7
+ * A unique, non-enumerable key for the internal event map.
8
+ * Using a Symbol prevents property name collisions on the consuming class instance,
9
+ * providing a robust way to manage private state within a mixin.
10
+ * @type {Symbol}
11
+ */
12
+ const eventMapSymbol = Symbol('eventMap');
13
+
5
14
  /**
6
15
  * @class Neo.core.Observable
7
16
  * @extends Neo.core.Base
@@ -19,12 +28,35 @@ class Observable extends Base {
19
28
  */
20
29
  ntype: 'mixin-observable',
21
30
  /**
22
- * @member {Boolean} mixin=true
23
- * @protected
31
+ * A declarative way to assign event listeners to an instance upon creation.
32
+ * The framework processes this config and calls `on()` to populate the
33
+ * internal event registry. This config should not be manipulated directly after
34
+ * instantiation; use `on()` and `un()` instead.
35
+ * @member {Object|null} listeners_
36
+ * @example
37
+ * listeners: {
38
+ * myEvent: 'onMyEvent',
39
+ * otherEvent: {
40
+ * fn: 'onOtherEvent',
41
+ * delay: 100,
42
+ * once: true
43
+ * },
44
+ * scope: this
45
+ * }
46
+ * @reactive
24
47
  */
25
- mixin: true
48
+ listeners_: {
49
+ [isDescriptor]: true,
50
+ merge : 'deep',
51
+ value : {}
52
+ }
26
53
  }
27
54
 
55
+ /**
56
+ * @member {Object} [eventMapSymbol]
57
+ * @private
58
+ */
59
+
28
60
  /**
29
61
  * @param {Object|String} name
30
62
  * @param {Object} [opts]
@@ -101,6 +133,12 @@ class Observable extends Base {
101
133
  }
102
134
 
103
135
  if (!nameObject) {
136
+ // LAZY INITIALIZATION: The key to a robust mixin.
137
+ // This ensures the private internal listener store exists on the instance.
138
+ // `eventMapSymbol` is the *actual* registry of handler arrays, and is
139
+ // intentionally separate from the public `listeners_` config.
140
+ me[eventMapSymbol] ??= {};
141
+
104
142
  eventConfig = {fn: listener, id: eventId || Neo.getId('event')};
105
143
 
106
144
  if (data) {eventConfig.data = data}
@@ -108,7 +146,7 @@ class Observable extends Base {
108
146
  if (once) {eventConfig.once = once}
109
147
  if (scope) {eventConfig.scope = scope}
110
148
 
111
- if (existing = me.listeners?.[name]) {
149
+ if ((existing = me[eventMapSymbol][name])) {
112
150
  existing.forEach(cfg => {
113
151
  if (cfg.id === eventId || (cfg.fn === listener && cfg.scope === scope)) {
114
152
  console.error('Duplicate event handler attached:', name, me)
@@ -123,7 +161,7 @@ class Observable extends Base {
123
161
  existing.push(eventConfig)
124
162
  }
125
163
  } else {
126
- me.listeners[name] = [eventConfig]
164
+ me[eventMapSymbol][name] = [eventConfig] // Use the private eventMapSymbol registry
127
165
  }
128
166
 
129
167
  return eventConfig.id
@@ -132,6 +170,25 @@ class Observable extends Base {
132
170
  return null
133
171
  }
134
172
 
173
+ /**
174
+ * This hook is the bridge between the declarative `listeners_` config and the
175
+ * imperative `on()`/`un()` methods. It's called automatically by the framework
176
+ * whenever the `listeners` config property is changed.
177
+ * @param {Object} value The new listeners object
178
+ * @param {Object} oldValue The old listeners object
179
+ * @protected
180
+ */
181
+ afterSetListeners(value, oldValue) {
182
+ // Unregister any listeners from the old config object
183
+ if (oldValue && Object.keys(oldValue).length > 0) {
184
+ this.un(oldValue)
185
+ }
186
+ // Register all listeners from the new config object
187
+ if (value && Object.keys(value).length > 0) {
188
+ this.on(value)
189
+ }
190
+ }
191
+
135
192
  /**
136
193
  * Call the passed function, or a function by *name* which exists in the passed scope's
137
194
  * or this component's ownership chain.
@@ -164,7 +221,7 @@ class Observable extends Base {
164
221
  fire(name) {
165
222
  let me = this,
166
223
  args = [].slice.call(arguments, 1),
167
- listeners = me.listeners,
224
+ listeners = me[eventMapSymbol], // Always use the private, structured registry for firing events.
168
225
  delay, handler, handlers, i, len;
169
226
 
170
227
  if (listeners && listeners[name]) {
@@ -203,50 +260,6 @@ class Observable extends Base {
203
260
  }
204
261
  }
205
262
 
206
- /**
207
- * @param {Object} config
208
- */
209
- initObservable(config) {
210
- let me = this,
211
- proto = me.__proto__,
212
- ctor = proto.constructor,
213
- listeners;
214
-
215
- if (config.listeners) {
216
- me.listeners = config.listeners;
217
- delete config.listeners
218
- }
219
-
220
- listeners = me.listeners;
221
-
222
- me.listeners = {};
223
-
224
- if (listeners) {
225
- if (Neo.isObject(listeners)) {
226
- listeners = {...listeners}
227
- }
228
-
229
- me.addListener(listeners);
230
- }
231
-
232
- while (proto?.constructor.isClass) {
233
- ctor = proto.constructor;
234
-
235
- if (ctor.observable && !ctor.listeners) {
236
- Object.assign(ctor, {
237
- addListener : me.addListener,
238
- fire : me.fire,
239
- listeners : {},
240
- on : me.on,
241
- removeListener: me.removeListener,
242
- un : me.un
243
- })
244
- }
245
-
246
- proto = proto.__proto__
247
- }
248
- }
249
-
250
263
  /**
251
264
  * Alias for addListener
252
265
  * @param {Object|String} name
@@ -287,6 +300,9 @@ class Observable extends Base {
287
300
  let me = this,
288
301
  i, len, listener, listeners, match;
289
302
 
303
+ // LAZY INITIALIZATION: Ensure the internal listener store exists.
304
+ me[eventMapSymbol] ??= {};
305
+
290
306
  if (Neo.isFunction(eventId)) {
291
307
  me.removeListener({[name]: eventId, scope});
292
308
  return
@@ -299,7 +315,7 @@ class Observable extends Base {
299
315
  }
300
316
 
301
317
  Object.entries(name).forEach(([key, value]) => {
302
- listeners = me.listeners[key] || [];
318
+ listeners = me[eventMapSymbol][key] || [];
303
319
  i = 0;
304
320
  len = listeners.length;
305
321
 
@@ -314,9 +330,9 @@ class Observable extends Base {
314
330
  break
315
331
  }
316
332
  }
317
- });
333
+ })
318
334
  } else if (Neo.isString(eventId)) {
319
- listeners = me.listeners[name];
335
+ listeners = me[eventMapSymbol][name];
320
336
  match = false;
321
337
 
322
338
  listeners.forEach((eventConfig, idx) => {
@@ -331,18 +347,6 @@ class Observable extends Base {
331
347
  }
332
348
  }
333
349
 
334
- // removeAllListeners: function(name) {
335
-
336
- // },
337
-
338
- // suspendListeners: function(queue) {
339
-
340
- // },
341
-
342
- // resumeListeners: function() {
343
-
344
- // }
345
-
346
350
  /**
347
351
  * Alias for removeListener
348
352
  * @param {Object|String} name
@@ -1391,13 +1391,19 @@ class Text extends Field {
1391
1391
  onInputValueChange(data) {
1392
1392
  let me = this,
1393
1393
  oldValue = me.value,
1394
- inputValue = data.value,
1395
- vnode = VNodeUtil.find(me.vnode, {nodeName: 'input'});
1394
+ inputValue = data.value;
1396
1395
 
1397
- if (vnode) {
1398
- // Update the current state (modified DOM by the user) to enable the delta-updates logic.
1396
+ // Find the VNode for the real input element within the component's vnode tree.
1397
+ const {vnode: inputVNode} = VNodeUtil.find(me.vnode, {nodeName: 'input'}) || {};
1398
+
1399
+ if (inputVNode) {
1400
+ // This is the critical synchronization step. The user's input has changed the
1401
+ // real DOM on the Main Thread. We must manually update our "last known state"
1402
+ // (this.vnode) to match this reality *before* the next diffing cycle runs.
1403
+ // This prevents the framework from sending a redundant delta update that could
1404
+ // overwrite the user's input or cause cursor jumps.
1399
1405
  // Required e.g. for validation -> revert a wrong user input
1400
- vnode.vnode.attributes.value = inputValue
1406
+ inputVNode.attributes.value = inputValue
1401
1407
  }
1402
1408
 
1403
1409
  if (Neo.isString(inputValue)) {