neo.mjs 10.0.0-beta.6 → 10.0.0

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 (51) 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/view/ViewportController.mjs +6 -4
  8. package/apps/portal/view/examples/List.mjs +28 -19
  9. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  10. package/examples/functional/button/base/MainContainer.mjs +207 -0
  11. package/examples/functional/button/base/app.mjs +6 -0
  12. package/examples/functional/button/base/index.html +11 -0
  13. package/examples/functional/button/base/neo-config.json +6 -0
  14. package/learn/blog/v10-deep-dive-functional-components.md +293 -0
  15. package/learn/blog/v10-deep-dive-reactivity.md +522 -0
  16. package/learn/blog/v10-deep-dive-state-provider.md +432 -0
  17. package/learn/blog/v10-deep-dive-vdom-revolution.md +194 -0
  18. package/learn/blog/v10-post1-love-story.md +383 -0
  19. package/learn/guides/uibuildingblocks/WorkingWithVDom.md +26 -2
  20. package/package.json +3 -3
  21. package/src/DefaultConfig.mjs +2 -2
  22. package/src/Neo.mjs +47 -45
  23. package/src/component/Abstract.mjs +412 -0
  24. package/src/component/Base.mjs +18 -380
  25. package/src/core/Base.mjs +34 -33
  26. package/src/core/Effect.mjs +30 -34
  27. package/src/core/EffectManager.mjs +101 -14
  28. package/src/core/Observable.mjs +69 -65
  29. package/src/form/field/Text.mjs +11 -5
  30. package/src/functional/button/Base.mjs +384 -0
  31. package/src/functional/component/Base.mjs +51 -145
  32. package/src/layout/Cube.mjs +8 -4
  33. package/src/manager/VDomUpdate.mjs +179 -94
  34. package/src/mixin/VdomLifecycle.mjs +4 -1
  35. package/src/state/Provider.mjs +41 -27
  36. package/src/util/VDom.mjs +11 -4
  37. package/src/util/vdom/TreeBuilder.mjs +38 -62
  38. package/src/worker/mixin/RemoteMethodAccess.mjs +1 -6
  39. package/test/siesta/siesta.js +15 -3
  40. package/test/siesta/tests/VdomCalendar.mjs +7 -7
  41. package/test/siesta/tests/VdomHelper.mjs +7 -7
  42. package/test/siesta/tests/classic/Button.mjs +113 -0
  43. package/test/siesta/tests/core/EffectBatching.mjs +46 -41
  44. package/test/siesta/tests/functional/Button.mjs +113 -0
  45. package/test/siesta/tests/state/ProviderNestedDataConfigs.mjs +59 -0
  46. package/test/siesta/tests/vdom/Advanced.mjs +14 -8
  47. package/test/siesta/tests/vdom/VdomAsymmetricUpdates.mjs +9 -9
  48. package/test/siesta/tests/vdom/VdomRealWorldUpdates.mjs +6 -6
  49. package/test/siesta/tests/vdom/layout/Cube.mjs +11 -7
  50. package/test/siesta/tests/vdom/table/Container.mjs +9 -5
  51. package/src/core/EffectBatchManager.mjs +0 -67
@@ -24,40 +24,43 @@ class TreeBuilder extends Base {
24
24
  }
25
25
 
26
26
  /**
27
- * Copies a given vdom tree and replaces child component references with the vdom of their matching components
28
- * @param {Object} vdom
29
- * @param {Number} [depth=-1]
30
- * The component replacement depth.
31
- * -1 will parse the full tree, 1 top level only, 2 include children, 3 include grandchildren
32
- * @param {Set<String>|null} [mergedChildIds=null] A set of component IDs to selectively expand.
27
+ * Private helper to recursively build a tree, abstracting the child node key.
28
+ * @param {Object} node The vdom or vnode to process.
29
+ * @param {Number} depth The current recursion depth.
30
+ * @param {Set<String>|null} mergedChildIds A set of component IDs to selectively expand.
31
+ * @param {String} childKey The property name for child nodes ('cn' or 'childNodes').
33
32
  * @returns {Object}
33
+ * @private
34
34
  */
35
- getVdomTree(vdom, depth = -1, mergedChildIds = null) {
36
- if (!Neo.isObject(vdom)) {
37
- return vdom
35
+ #buildTree(node, depth, mergedChildIds, childKey) {
36
+ // We can not use Neo.isObject() here, since inside unit-test scenarios, we will import vdom.Helper into main threads.
37
+ // Inside this scenario, Neo.isObject() returns false for VNode instances
38
+ if (typeof node !== 'object' || node === null) {
39
+ return node
38
40
  }
39
41
 
40
- let output = {...vdom}; // Shallow copy
42
+ let output = {...node}; // Shallow copy
41
43
 
42
- if (vdom.cn) {
43
- output.cn = [];
44
+ if (node[childKey]) {
45
+ output[childKey] = [];
44
46
 
45
- vdom.cn.forEach(item => {
47
+ node[childKey].forEach(item => {
46
48
  let currentItem = item,
47
49
  childDepth;
48
50
 
49
51
  if (currentItem.componentId) {
50
52
  // Prune the branch only if we are at the boundary AND the child is not part of a merged update
51
53
  if (depth === 1 && !mergedChildIds?.has(currentItem.componentId)) {
52
- output.cn.push({componentId: 'neo-ignore', id: item.id || item.componentId});
53
- // Stop processing this branch
54
- return
54
+ output[childKey].push({componentId: 'neo-ignore', id: item.id || item.componentId});
55
+ return // Stop processing this branch
55
56
  }
56
57
  // Expand the branch if it's part of a merged update, or if the depth requires it
57
58
  else if (depth > 1 || depth === -1 || mergedChildIds?.has(currentItem.componentId)) {
58
59
  const component = ComponentManager.get(currentItem.componentId);
59
- if (component?.vdom) {
60
- currentItem = component.vdom
60
+ // Use the correct tree type based on the childKey
61
+ const componentTree = childKey === 'cn' ? component?.vdom : component?.vnode;
62
+ if (componentTree) {
63
+ currentItem = componentTree
61
64
  }
62
65
  }
63
66
  }
@@ -68,61 +71,34 @@ class TreeBuilder extends Base {
68
71
  childDepth = depth
69
72
  }
70
73
 
71
- output.cn.push(this.getVdomTree(currentItem, childDepth, mergedChildIds))
74
+ output[childKey].push(this.#buildTree(currentItem, childDepth, mergedChildIds, childKey))
72
75
  })
73
76
  }
74
77
 
75
78
  return output
76
79
  }
77
80
 
81
+
78
82
  /**
79
- * Copies a given vnode tree and replaces child component references with the vnode of their matching components
80
- * @param {Object} vnode
83
+ * Copies a given vdom tree and replaces child component references with their vdom.
84
+ * @param {Object} vdom
81
85
  * @param {Number} [depth=-1]
82
- * The component replacement depth.
83
- * -1 will parse the full tree, 1 top level only, 2 include children, 3 include grandchildren
84
- * @param {Set<String>|null} [mergedChildIds=null] A set of component IDs to selectively expand.
86
+ * @param {Set<String>|null} [mergedChildIds=null]
85
87
  * @returns {Object}
86
88
  */
87
- getVnodeTree(vnode, depth = -1, mergedChildIds = null) {
88
- let output = {...vnode}; // Shallow copy
89
-
90
- if (vnode.childNodes) {
91
- output.childNodes = [];
92
-
93
- vnode.childNodes.forEach(item => {
94
- let currentItem = item,
95
- childDepth, component;
96
-
97
- if (currentItem.componentId) {
98
- // Prune the branch only if we are at the boundary AND the child is not part of a merged update
99
- if (depth === 1 && !mergedChildIds?.has(currentItem.componentId)) {
100
- output.childNodes.push({componentId: 'neo-ignore', id: item.id || item.componentId});
101
- // Stop processing this branch
102
- return
103
- }
104
- // Expand the branch if it's part of a merged update, or if the depth requires it
105
- else if (depth > 1 || depth === -1 || mergedChildIds?.has(currentItem.componentId)) {
106
- component = ComponentManager.get(currentItem.componentId);
107
-
108
- // Keep references in case there is no vnode (e.g. component not mounted yet)
109
- if (component?.vnode) {
110
- currentItem = component.vnode
111
- }
112
- }
113
- }
114
-
115
- if (item.componentId) {
116
- childDepth = (depth === -1) ? -1 : Math.max(0, depth - 1)
117
- } else {
118
- childDepth = depth
119
- }
120
-
121
- output.childNodes.push(this.getVnodeTree(currentItem, childDepth, mergedChildIds))
122
- })
123
- }
89
+ getVdomTree(vdom, depth=-1, mergedChildIds=null) {
90
+ return this.#buildTree(vdom, depth, mergedChildIds, 'cn')
91
+ }
124
92
 
125
- return output
93
+ /**
94
+ * Copies a given vnode tree and replaces child component references with their vnode.
95
+ * @param {Object} vnode
96
+ * @param {Number} [depth=-1]
97
+ * @param {Set<String>|null} [mergedChildIds=null]
98
+ * @returns {Object}
99
+ */
100
+ getVnodeTree(vnode, depth=-1, mergedChildIds=null) {
101
+ return this.#buildTree(vnode, depth, mergedChildIds, 'childNodes')
126
102
  }
127
103
  }
128
104
 
@@ -10,12 +10,7 @@ class RemoteMethodAccess extends Base {
10
10
  * @member {String} className='Neo.worker.mixin.RemoteMethodAccess'
11
11
  * @protected
12
12
  */
13
- className: 'Neo.worker.mixin.RemoteMethodAccess',
14
- /**
15
- * @member {Boolean} mixin=true
16
- * @protected
17
- */
18
- mixin: true
13
+ className: 'Neo.worker.mixin.RemoteMethodAccess'
19
14
  }
20
15
 
21
16
  /**
@@ -70,9 +70,21 @@ project.plan(
70
70
  'tests/vdom/table/Container.mjs'
71
71
  ]
72
72
  },
73
- 'tests/vdom/Advanced.mjs',
74
- 'tests/vdom/VdomAsymmetricUpdates.mjs',
75
- 'tests/vdom/VdomRealWorldUpdates.mjs']
73
+ 'tests/vdom/Advanced.mjs',
74
+ 'tests/vdom/VdomAsymmetricUpdates.mjs',
75
+ 'tests/vdom/VdomRealWorldUpdates.mjs']
76
+ },
77
+ {
78
+ group: 'functional',
79
+ items: [
80
+ 'tests/functional/Button.mjs'
81
+ ]
82
+ },
83
+ {
84
+ group: 'classic',
85
+ items: [
86
+ 'tests/classic/Button.mjs'
87
+ ]
76
88
  }
77
89
  );
78
90
 
@@ -1,15 +1,15 @@
1
- import Neo from '../../../src/Neo.mjs';
2
- import * as core from '../../../src/core/_export.mjs';
3
- import NeoArray from '../../../src/util/Array.mjs';
4
- import Style from '../../../src/util/Style.mjs';
5
- import VdomHelper from '../../../src/vdom/Helper.mjs';
6
- import VDomUtil from '../../../src/util/VDom.mjs';
1
+ import Neo from '../../../src/Neo.mjs';
2
+ import * as core from '../../../src/core/_export.mjs';
3
+ import NeoArray from '../../../src/util/Array.mjs';
4
+ import Style from '../../../src/util/Style.mjs';
5
+ import StringFromVnode from '../../../src/vdom/util/StringFromVnode.mjs';
6
+ import VdomHelper from '../../../src/vdom/Helper.mjs';
7
+ import VDomUtil from '../../../src/util/VDom.mjs';
7
8
 
8
9
  let deltas, output, vdom, vnode;
9
10
 
10
11
  // tests are designed for this rendering mode
11
12
  Neo.config.useDomApiRenderer = false;
12
- VdomHelper.onNeoConfigChange({useDomApiRenderer: false})
13
13
 
14
14
  StartTest(t => {
15
15
  t.it('Week view: Infinite Scrolling', t => {
@@ -1,13 +1,13 @@
1
- import Neo from '../../../src/Neo.mjs';
2
- import * as core from '../../../src/core/_export.mjs';
3
- import NeoArray from '../../../src/util/Array.mjs';
4
- import Style from '../../../src/util/Style.mjs';
5
- import VdomHelper from '../../../src/vdom/Helper.mjs';
6
- import VDomUtil from '../../../src/util/VDom.mjs';
1
+ import Neo from '../../../src/Neo.mjs';
2
+ import * as core from '../../../src/core/_export.mjs';
3
+ import NeoArray from '../../../src/util/Array.mjs';
4
+ import Style from '../../../src/util/Style.mjs';
5
+ import StringFromVnode from '../../../src/vdom/util/StringFromVnode.mjs';
6
+ import VdomHelper from '../../../src/vdom/Helper.mjs';
7
+ import VDomUtil from '../../../src/util/VDom.mjs';
7
8
 
8
9
  // tests are designed for this rendering mode
9
10
  Neo.config.useDomApiRenderer = false;
10
- VdomHelper.onNeoConfigChange({useDomApiRenderer: false})
11
11
 
12
12
  let deltas, output, tmp, vdom, vnode;
13
13
 
@@ -0,0 +1,113 @@
1
+ import Neo from '../../../../src/Neo.mjs';
2
+ import * as core from '../../../../src/core/_export.mjs';
3
+ import Button from '../../../../src/button/Base.mjs';
4
+ import DomApiVnodeCreator from '../../../../src/vdom/util/DomApiVnodeCreator.mjs';
5
+ import VdomHelper from '../../../../src/vdom/Helper.mjs';
6
+
7
+ // IMPORTANT: This test file uses real components and expects them to render.
8
+ // We need to enable unitTestMode for isolation, but also allow VDOM updates.
9
+ Neo.config.unitTestMode = true;
10
+ Neo.config.allowVdomUpdatesInTests = true;
11
+ // This ensures that the VdomHelper uses the correct renderer for the assertions.
12
+ Neo.config.useDomApiRenderer = true;
13
+
14
+ // Create a mock application context, as the component lifecycle requires it for updates.
15
+ const appName = 'ClassicButtonTest';
16
+ Neo.apps = Neo.apps || {};
17
+ Neo.apps[appName] = {
18
+ name : appName,
19
+ fire : Neo.emptyFn,
20
+ isMounted: () => true,
21
+ rendering: false
22
+ };
23
+
24
+ StartTest(t => {
25
+ let button, vnode;
26
+ let testRun = 0;
27
+
28
+ t.beforeEach(async t => {
29
+ testRun++;
30
+
31
+ button = Neo.create(Button, {
32
+ appName,
33
+ id : 'my-button-' + testRun,
34
+ iconCls: 'fa fa-home',
35
+ text : 'Click me'
36
+ });
37
+
38
+ ({vnode} = await button.render());
39
+ button.mounted = true; // Manually mount to enable updates in the test env
40
+ });
41
+
42
+ t.afterEach(t => {
43
+ button?.destroy();
44
+ button = null;
45
+ vnode = null;
46
+ });
47
+
48
+ t.it('should create initial vnode correctly', async t => {
49
+ t.expect(vnode.nodeName).toBe('button');
50
+ t.expect(vnode.className).toEqual(['neo-button', 'icon-left']);
51
+ t.expect(vnode.childNodes.length).toBe(2); // icon & text. badge & ripple have removeDom:true
52
+
53
+ const iconNode = vnode.childNodes[0];
54
+ t.expect(iconNode.className).toEqual(['neo-button-glyph', 'fa', 'fa-home']);
55
+
56
+ const textNode = vnode.childNodes[1];
57
+ t.expect(textNode.className).toEqual(['neo-button-text']);
58
+ t.expect(textNode.textContent).toBe('Click me');
59
+ });
60
+
61
+ t.it('should update vnode and create delta for a single config change', async t => {
62
+ const textNodeId = vnode.childNodes[1].id;
63
+ const {deltas} = await button.set({text: 'New Text'});
64
+
65
+ t.is(deltas.length, 1, 'Should generate exactly one delta');
66
+ const delta = deltas[0];
67
+
68
+ t.is(delta.id, textNodeId, 'Delta should target the text node');
69
+ t.is(delta.textContent, 'New Text', 'Delta textContent is correct');
70
+ });
71
+
72
+ t.it('should update vnode and create delta for multiple config changes', async t => {
73
+ const iconNodeId = vnode.childNodes[0].id;
74
+ const textNodeId = vnode.childNodes[1].id;
75
+
76
+ const {deltas} = await button.set({
77
+ iconCls: 'fa fa-user',
78
+ text : 'Submit'
79
+ });
80
+
81
+ t.is(deltas.length, 2, 'Should generate exactly two deltas');
82
+
83
+ const iconDelta = deltas.find(d => d.id === iconNodeId);
84
+ const textDelta = deltas.find(d => d.id === textNodeId);
85
+
86
+ t.ok(iconDelta, 'Should have a delta for the icon node');
87
+ t.isDeeply(iconDelta.cls.remove, ['fa-home'], 'Icon delta should remove old class');
88
+ t.isDeeply(iconDelta.cls.add, ['fa-user'], 'Icon delta should add new class');
89
+
90
+ t.ok(textDelta, 'Should have a delta for the text node');
91
+ t.is(textDelta.textContent, 'Submit', 'Text delta is correct');
92
+ });
93
+
94
+ t.it('should handle pressed state change', async t => {
95
+ t.notOk(vnode.className.includes('pressed'), 'Initial vnode should not have "pressed" class');
96
+
97
+ let updateData = await button.set({pressed: true});
98
+
99
+ t.is(updateData.deltas.length, 1, 'Should generate one delta for pressed: true');
100
+ let delta = updateData.deltas[0];
101
+ t.is(delta.id, button.id, 'Delta should target the button component root');
102
+ t.isDeeply(delta.cls.add, ['pressed'], 'Delta should add "pressed" class');
103
+ t.ok(updateData.vnode.className.includes('pressed'), 'Vnode should have "pressed" class');
104
+
105
+ updateData = await button.set({pressed: false});
106
+
107
+ t.is(updateData.deltas.length, 1, 'Should generate one delta for pressed: false');
108
+ delta = updateData.deltas[0];
109
+ t.is(delta.id, button.id, 'Delta should target the button component root');
110
+ t.isDeeply(delta.cls.remove, ['pressed'], 'Delta should remove "pressed" class');
111
+ t.notOk(updateData.vnode.className.includes('pressed'), 'Vnode should not have "pressed" class');
112
+ });
113
+ });
@@ -1,23 +1,24 @@
1
- import Neo from '../../../../src/Neo.mjs';
2
- import * as core from '../../../../src/core/_export.mjs';
3
- import Effect from '../../../../src/core/Effect.mjs';
4
- import EffectBatchManager from '../../../../src/core/EffectBatchManager.mjs';
1
+ import Neo from '../../../../src/Neo.mjs';
2
+ import * as core from '../../../../src/core/_export.mjs';
3
+ import Effect from '../../../../src/core/Effect.mjs';
4
+ import EffectManager from '../../../../src/core/EffectManager.mjs';
5
5
 
6
6
  class TestComponent extends core.Base {
7
7
  static config = {
8
8
  className: 'Neo.Test.EffectBatchingComponent',
9
- configA_: 0,
10
- configB_: 0,
11
- configC_: 0
9
+ configA_ : 0,
10
+ configB_ : 0,
11
+ configC_ : 0
12
12
  }
13
13
  }
14
+
14
15
  Neo.setupClass(TestComponent);
15
16
 
16
17
  StartTest(t => {
17
18
  t.it('Effects should be batched during core.Base#set() operations', t => {
18
- const instance = Neo.create(TestComponent);
19
+ const instance = Neo.create(TestComponent);
19
20
  let effectRunCount = 0;
20
- let sum = 0;
21
+ let sum = 0;
21
22
 
22
23
  const effect = new Effect(() => {
23
24
  effectRunCount++;
@@ -47,7 +48,7 @@ StartTest(t => {
47
48
  t.is(instance.configC, 3, 'configC should be updated');
48
49
 
49
50
  // Test individual property change outside a batch
50
- effectRunCount = 0;
51
+ effectRunCount = 0;
51
52
  instance.configA = 10;
52
53
  t.is(effectRunCount, 1, 'Effect should run immediately for individual property change outside a batch');
53
54
  t.is(sum, 10 + 2 + 3, 'Sum should be 10 + 2 + 3 = 15 after individual configA change');
@@ -58,8 +59,8 @@ StartTest(t => {
58
59
  // Update all configs to their previous values (no change)
59
60
  instance.set({
60
61
  configA: 10,
61
- configB: 2,
62
- configC: 3
62
+ configB: 2,
63
+ configC: 3
63
64
  });
64
65
 
65
66
  t.is(effectRunCount, 0, 'Effect should not run.');
@@ -68,28 +69,28 @@ StartTest(t => {
68
69
  instance.destroy();
69
70
  });
70
71
 
71
- t.it('EffectBatchManager should correctly manage batch state', t => {
72
- t.is(EffectBatchManager.isBatchActive(), false, 'Batch should not be active initially');
72
+ t.it('EffectManager should correctly manage batch state via pause() and resume()', t => {
73
+ t.is(EffectManager.isPaused(), false, 'Batch should not be active initially');
73
74
 
74
- EffectBatchManager.startBatch();
75
- t.is(EffectBatchManager.isBatchActive(), true, 'Batch should be active after startBatch()');
75
+ EffectManager.pause();
76
+ t.is(EffectManager.isPaused(), true, 'Batch should be active after pause()');
76
77
 
77
- EffectBatchManager.startBatch(); // Nested batch
78
- t.is(EffectBatchManager.isBatchActive(), true, 'Batch should still be active for nested batch');
78
+ EffectManager.pause(); // Nested batch
79
+ t.is(EffectManager.isPaused(), true, 'Batch should still be active for nested pause()');
79
80
 
80
- EffectBatchManager.endBatch();
81
- t.is(EffectBatchManager.isBatchActive(), true, 'Batch should still be active after inner endBatch()');
81
+ EffectManager.resume();
82
+ t.is(EffectManager.isPaused(), true, 'Batch should still be active after inner resume()');
82
83
 
83
- EffectBatchManager.endBatch();
84
- t.is(EffectBatchManager.isBatchActive(), false, 'Batch should not be active after all endBatch() calls');
84
+ EffectManager.resume();
85
+ t.is(EffectManager.isPaused(), false, 'Batch should not be active after all resume() calls');
85
86
  });
86
87
 
87
88
  t.it('Effect should run when a dependency is changed inside an afterSet hook', t => {
88
89
  class IndirectDependencyComponent extends core.Base {
89
90
  static config = {
90
91
  className: 'Neo.Test.IndirectDependencyComponent',
91
- configA_: 'initialA',
92
- configB_: 'initialB'
92
+ configA_ : 'initialA',
93
+ configB_ : 'initialB'
93
94
  }
94
95
 
95
96
  afterSetConfigA(newValue, oldValue) {
@@ -99,11 +100,12 @@ StartTest(t => {
99
100
  }
100
101
  }
101
102
  }
103
+
102
104
  Neo.setupClass(IndirectDependencyComponent);
103
105
 
104
- const instance = Neo.create(IndirectDependencyComponent);
106
+ const instance = Neo.create(IndirectDependencyComponent);
105
107
  let effectRunCount = 0;
106
- let effectValue = '';
108
+ let effectValue = '';
107
109
 
108
110
  // Effect depends only on configB
109
111
  const effect = new Effect(() => {
@@ -135,8 +137,8 @@ StartTest(t => {
135
137
  class BeforeSetDependencyComponent extends core.Base {
136
138
  static config = {
137
139
  className: 'Neo.Test.BeforeSetDependencyComponent',
138
- configA_: 'initialA',
139
- configC_: 'initialC' // configC will be changed by beforeSetConfigA
140
+ configA_ : 'initialA',
141
+ configC_ : 'initialC' // configC will be changed by beforeSetConfigA
140
142
  }
141
143
 
142
144
  beforeSetConfigA(newValue, oldValue) {
@@ -147,11 +149,12 @@ StartTest(t => {
147
149
  return newValue; // Important: return newValue to allow configA to be set
148
150
  }
149
151
  }
152
+
150
153
  Neo.setupClass(BeforeSetDependencyComponent);
151
154
 
152
- const instance = Neo.create(BeforeSetDependencyComponent);
155
+ const instance = Neo.create(BeforeSetDependencyComponent);
153
156
  let effectRunCount = 0;
154
- let effectValue = '';
157
+ let effectValue = '';
155
158
 
156
159
  // Effect depends only on configC
157
160
  const effect = new Effect(() => {
@@ -183,9 +186,9 @@ StartTest(t => {
183
186
  class CombinedDependencyComponent extends core.Base {
184
187
  static config = {
185
188
  className: 'Neo.Test.CombinedDependencyComponent',
186
- configA_: 'initialA',
187
- configB_: 'initialB', // Changed by afterSet
188
- configC_: 'initialC' // Changed by beforeSet
189
+ configA_ : 'initialA',
190
+ configB_ : 'initialB', // Changed by afterSet
191
+ configC_ : 'initialC' // Changed by beforeSet
189
192
  }
190
193
 
191
194
  beforeSetConfigA(newValue, oldValue) {
@@ -201,13 +204,14 @@ StartTest(t => {
201
204
  }
202
205
  }
203
206
  }
207
+
204
208
  Neo.setupClass(CombinedDependencyComponent);
205
209
 
206
- const instance = Neo.create(CombinedDependencyComponent);
210
+ const instance = Neo.create(CombinedDependencyComponent);
207
211
  let effectBRunCount = 0;
208
212
  let effectCRunCount = 0;
209
- let effectBValue = '';
210
- let effectCValue = '';
213
+ let effectBValue = '';
214
+ let effectCValue = '';
211
215
 
212
216
  // Effect for configB (changed by afterSet)
213
217
  const effectB = new Effect(() => {
@@ -257,9 +261,9 @@ StartTest(t => {
257
261
  class SingleEffectCombinedDependencyComponent extends core.Base {
258
262
  static config = {
259
263
  className: 'Neo.Test.SingleEffectCombinedDependencyComponent',
260
- configA_: 'initialA',
261
- configB_: 'initialB', // Changed by afterSet
262
- configC_: 'initialC' // Changed by beforeSet
264
+ configA_ : 'initialA',
265
+ configB_ : 'initialB', // Changed by afterSet
266
+ configC_ : 'initialC' // Changed by beforeSet
263
267
  }
264
268
 
265
269
  beforeSetConfigA(newValue, oldValue) {
@@ -275,11 +279,12 @@ StartTest(t => {
275
279
  }
276
280
  }
277
281
  }
282
+
278
283
  Neo.setupClass(SingleEffectCombinedDependencyComponent);
279
284
 
280
- const instance = Neo.create(SingleEffectCombinedDependencyComponent);
285
+ const instance = Neo.create(SingleEffectCombinedDependencyComponent);
281
286
  let effectRunCount = 0;
282
- let effectValue = '';
287
+ let effectValue = '';
283
288
 
284
289
  // Single Effect depends on both configB and configC
285
290
  const effect = new Effect(() => {
@@ -0,0 +1,113 @@
1
+ import Neo from '../../../../src/Neo.mjs';
2
+ import * as core from '../../../../src/core/_export.mjs';
3
+ import Button from '../../../../src/functional/button/Base.mjs';
4
+ import DomApiVnodeCreator from '../../../../src/vdom/util/DomApiVnodeCreator.mjs';
5
+ import VdomHelper from '../../../../src/vdom/Helper.mjs';
6
+
7
+ // IMPORTANT: This test file uses real components and expects them to render.
8
+ // We need to enable unitTestMode for isolation, but also allow VDOM updates.
9
+ Neo.config.unitTestMode = true;
10
+ Neo.config.allowVdomUpdatesInTests = true;
11
+ // This ensures that the VdomHelper uses the correct renderer for the assertions.
12
+ Neo.config.useDomApiRenderer = true;
13
+
14
+ // Create a mock application context, as the component lifecycle requires it for updates.
15
+ const appName = 'FunctionalButtonTest';
16
+ Neo.apps = Neo.apps || {};
17
+ Neo.apps[appName] = {
18
+ name : appName,
19
+ fire : Neo.emptyFn,
20
+ isMounted: () => true,
21
+ rendering: false
22
+ };
23
+
24
+ StartTest(t => {
25
+ let button, vnode;
26
+ let testRun = 0;
27
+
28
+ t.beforeEach(async t => {
29
+ testRun++;
30
+
31
+ button = Neo.create(Button, {
32
+ appName,
33
+ id : 'my-button-' + testRun,
34
+ iconCls: 'fa fa-home',
35
+ text : 'Click me'
36
+ });
37
+
38
+ ({vnode} = await button.render());
39
+ button.mounted = true; // Manually mount to enable updates in the test env
40
+ });
41
+
42
+ t.afterEach(t => {
43
+ button?.destroy();
44
+ button = null;
45
+ vnode = null;
46
+ });
47
+
48
+ t.it('should create initial vnode correctly', async t => {
49
+ t.expect(vnode.nodeName).toBe('button');
50
+ t.expect(vnode.className).toEqual(['neo-button', 'icon-left']);
51
+ t.expect(vnode.childNodes.length).toBe(2); // icon & text. badge & ripple have removeDom:true
52
+
53
+ const iconNode = vnode.childNodes[0];
54
+ t.expect(iconNode.className).toEqual(['neo-button-glyph', 'fa', 'fa-home']);
55
+
56
+ const textNode = vnode.childNodes[1];
57
+ t.expect(textNode.className).toEqual(['neo-button-text']);
58
+ t.expect(textNode.textContent).toBe('Click me');
59
+ });
60
+
61
+ t.it('should update vnode and create delta for a single config change', async t => {
62
+ const textNodeId = vnode.childNodes[1].id;
63
+ const {deltas} = await button.set({text: 'New Text'});
64
+
65
+ t.is(deltas.length, 1, 'Should generate exactly one delta');
66
+ const delta = deltas[0];
67
+
68
+ t.is(delta.id, textNodeId, 'Delta should target the text node');
69
+ t.is(delta.textContent, 'New Text', 'Delta textContent is correct');
70
+ });
71
+
72
+ t.it('should update vnode and create delta for multiple config changes', async t => {
73
+ const iconNodeId = vnode.childNodes[0].id;
74
+ const textNodeId = vnode.childNodes[1].id;
75
+
76
+ const {deltas} = await button.set({
77
+ iconCls: 'fa fa-user',
78
+ text : 'Submit'
79
+ });
80
+
81
+ t.is(deltas.length, 2, 'Should generate exactly two deltas');
82
+
83
+ const iconDelta = deltas.find(d => d.id === iconNodeId);
84
+ const textDelta = deltas.find(d => d.id === textNodeId);
85
+
86
+ t.ok(iconDelta, 'Should have a delta for the icon node');
87
+ t.isDeeply(iconDelta.cls.remove, ['fa-home'], 'Icon delta should remove old class');
88
+ t.isDeeply(iconDelta.cls.add, ['fa-user'], 'Icon delta should add new class');
89
+
90
+ t.ok(textDelta, 'Should have a delta for the text node');
91
+ t.is(textDelta.textContent, 'Submit', 'Text delta is correct');
92
+ });
93
+
94
+ t.it('should handle pressed state change', async t => {
95
+ t.notOk(vnode.className.includes('pressed'), 'Initial vnode should not have "pressed" class');
96
+
97
+ let updateData = await button.set({pressed: true});
98
+
99
+ t.is(updateData.deltas.length, 1, 'Should generate one delta for pressed: true');
100
+ let delta = updateData.deltas[0];
101
+ t.is(delta.id, button.id, 'Delta should target the button component root');
102
+ t.isDeeply(delta.cls.add, ['pressed'], 'Delta should add "pressed" class');
103
+ t.ok(updateData.vnode.className.includes('pressed'), 'Vnode should have "pressed" class');
104
+
105
+ updateData = await button.set({pressed: false});
106
+
107
+ t.is(updateData.deltas.length, 1, 'Should generate one delta for pressed: false');
108
+ delta = updateData.deltas[0];
109
+ t.is(delta.id, button.id, 'Delta should target the button component root');
110
+ t.isDeeply(delta.cls.remove, ['pressed'], 'Delta should remove "pressed" class');
111
+ t.notOk(updateData.vnode.className.includes('pressed'), 'Vnode should not have "pressed" class');
112
+ });
113
+ });