slicejs-web-framework 4.0.1 → 4.0.2

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 (71) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/LICENSE +21 -21
  3. package/README.md +97 -97
  4. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  5. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
  6. package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
  7. package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
  8. package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
  9. package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
  10. package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
  11. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
  12. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
  13. package/Slice/Components/Structural/Logger/Log.js +10 -10
  14. package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
  15. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  16. package/Slice/Components/Structural/Router/Router.js +810 -793
  17. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  18. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -162
  19. package/Slice/Slice.js +708 -685
  20. package/Slice/tests/build-bundled-component-without-category.test.js +103 -103
  21. package/Slice/tests/build-js-only-visual-components.test.js +144 -144
  22. package/Slice/tests/build-singleton.test.js +244 -244
  23. package/Slice/tests/bundle-v2-runtime-contract.test.js +747 -738
  24. package/Slice/tests/context-patch-use.test.js +95 -95
  25. package/Slice/tests/destroy-cascade.test.js +215 -215
  26. package/Slice/tests/event-emitters.test.js +79 -79
  27. package/Slice/tests/event-registry.test.js +141 -141
  28. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  29. package/Slice/tests/leak-orphans.test.js +131 -131
  30. package/Slice/tests/props-allowed-values-validation.test.js +119 -119
  31. package/Slice/tests/public-env-runtime-accessors.test.js +44 -44
  32. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  33. package/Slice/tests/router-loading-finally.test.js +68 -68
  34. package/api/framework/devDepsOptimizer.js +335 -335
  35. package/api/framework/express.js +3 -3
  36. package/api/framework/precompressedStatic.js +93 -93
  37. package/api/framework/publicEnvResolver.js +123 -123
  38. package/api/framework/securityMiddleware.js +197 -197
  39. package/api/framework/server.js +411 -411
  40. package/api/index.js +7 -7
  41. package/api/tests/dev-deps-optimizer.test.js +137 -137
  42. package/api/tests/dev-server-external.test.js +56 -56
  43. package/api/tests/precompressed-static.test.js +119 -119
  44. package/api/tests/public-env-resolver.test.js +193 -193
  45. package/package.json +49 -49
  46. package/sliceConfig.schema.json +122 -122
  47. package/src/App/index.html +69 -69
  48. package/src/App/index.js +20 -20
  49. package/src/App/style.css +11 -11
  50. package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
  51. package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
  52. package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
  53. package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
  54. package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
  55. package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
  56. package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
  57. package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
  58. package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
  59. package/src/Components/components.js +8 -8
  60. package/src/public/Styles/sliceStyles.css +34 -34
  61. package/src/public/Themes/Dark.css +42 -42
  62. package/src/public/Themes/Light.css +31 -31
  63. package/src/public/Themes/Slice.css +47 -47
  64. package/src/public/manifest.json +15 -15
  65. package/src/public/robots.txt +4 -4
  66. package/src/public/sitemap.xml +8 -8
  67. package/src/routes.js +9 -9
  68. package/src/sliceConfig.json +72 -72
  69. package/tests/router-build-failure.test.js +125 -0
  70. package/tests/router-emit-ordering.test.js +63 -63
  71. package/types/index.d.ts +219 -219
@@ -1,95 +1,95 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
-
4
- globalThis.alert = () => {};
5
-
6
- // Minimal global `slice` so ContextManager's slice.logger / slice.events work in
7
- // isolation (a tiny in-memory event bus drives the watch notifications).
8
- const subscribers = new Map(); // eventName -> Set<fn>
9
- let subId = 0;
10
- globalThis.slice = {
11
- logger: { logError() {}, logInfo() {}, logWarning() {} },
12
- events: {
13
- subscribe(name, cb) {
14
- if (!subscribers.has(name)) subscribers.set(name, new Set());
15
- subscribers.get(name).add(cb);
16
- return `sub-${++subId}`;
17
- },
18
- emit(name, ...args) {
19
- for (const cb of subscribers.get(name) ?? []) cb(...args);
20
- },
21
- },
22
- };
23
-
24
- const { default: ContextManager } = await import(
25
- '../Components/Structural/ContextManager/ContextManager.js'
26
- );
27
-
28
- function freshContext(name, initial) {
29
- const ctx = new ContextManager();
30
- ctx.create(name, initial);
31
- return ctx;
32
- }
33
-
34
- test('patch merges into existing state (does not replace)', () => {
35
- const ctx = freshContext('cart', { items: 2, total: 47, discount: 0 });
36
- ctx.patch('cart', { discount: 0.1 });
37
- assert.deepEqual(ctx.getState('cart'), { items: 2, total: 47, discount: 0.1 });
38
- });
39
-
40
- test('setState replaces, patch keeps the rest', () => {
41
- const ctx = freshContext('s', { a: 1, b: 2 });
42
- ctx.setState('s', { a: 9 }); // replace → drops b
43
- assert.deepEqual(ctx.getState('s'), { a: 9 });
44
- ctx.patch('s', { b: 5 }); // merge onto { a: 9 }
45
- assert.deepEqual(ctx.getState('s'), { a: 9, b: 5 });
46
- });
47
-
48
- test('patch rejects non-object partials and missing contexts (no throw)', () => {
49
- const ctx = freshContext('s', { a: 1 });
50
- ctx.patch('s', null);
51
- ctx.patch('s', [1, 2]);
52
- ctx.patch('nope', { x: 1 });
53
- assert.deepEqual(ctx.getState('s'), { a: 1 });
54
- assert.equal(ctx.has('nope'), false);
55
- });
56
-
57
- test('use(name) returns a handle bound to that context', () => {
58
- const ctx = freshContext('settings', { model: 'a' });
59
- const settings = ctx.use('settings');
60
-
61
- assert.equal(settings.get().model, 'a');
62
- settings.set({ model: 'b' });
63
- assert.equal(ctx.getState('settings').model, 'b');
64
- settings.patch({ theme: 'dark' });
65
- assert.deepEqual(ctx.getState('settings'), { model: 'b', theme: 'dark' });
66
- assert.equal(settings.has(), true);
67
- settings.destroy();
68
- assert.equal(ctx.has('settings'), false);
69
- });
70
-
71
- test('use(name).bind calls back immediately and on every change', () => {
72
- const ctx = freshContext('cart', { items: [], n: 0 });
73
- const calls = [];
74
- ctx.use('cart').bind({ sliceId: 'c1' }, (state) => calls.push(state.n));
75
-
76
- assert.deepEqual(calls, [0]); // immediate initial call
77
- ctx.patch('cart', { n: 1 });
78
- assert.deepEqual(calls, [0, 1]); // fired on change
79
- });
80
-
81
- test('use(name).bind with a selector only fires when the selected value changes', () => {
82
- const ctx = freshContext('cart', { items: ['x'], note: 'a' });
83
- const counts = [];
84
- ctx.use('cart').bind(
85
- { sliceId: 'c2' },
86
- (count) => counts.push(count),
87
- (s) => s.items.length
88
- );
89
-
90
- assert.deepEqual(counts, [1]); // immediate, selected value = length
91
- ctx.patch('cart', { note: 'b' }); // length unchanged → no fire
92
- assert.deepEqual(counts, [1]);
93
- ctx.patch('cart', { items: ['x', 'y'] }); // length changed → fire
94
- assert.deepEqual(counts, [1, 2]);
95
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ globalThis.alert = () => {};
5
+
6
+ // Minimal global `slice` so ContextManager's slice.logger / slice.events work in
7
+ // isolation (a tiny in-memory event bus drives the watch notifications).
8
+ const subscribers = new Map(); // eventName -> Set<fn>
9
+ let subId = 0;
10
+ globalThis.slice = {
11
+ logger: { logError() {}, logInfo() {}, logWarning() {} },
12
+ events: {
13
+ subscribe(name, cb) {
14
+ if (!subscribers.has(name)) subscribers.set(name, new Set());
15
+ subscribers.get(name).add(cb);
16
+ return `sub-${++subId}`;
17
+ },
18
+ emit(name, ...args) {
19
+ for (const cb of subscribers.get(name) ?? []) cb(...args);
20
+ },
21
+ },
22
+ };
23
+
24
+ const { default: ContextManager } = await import(
25
+ '../Components/Structural/ContextManager/ContextManager.js'
26
+ );
27
+
28
+ function freshContext(name, initial) {
29
+ const ctx = new ContextManager();
30
+ ctx.create(name, initial);
31
+ return ctx;
32
+ }
33
+
34
+ test('patch merges into existing state (does not replace)', () => {
35
+ const ctx = freshContext('cart', { items: 2, total: 47, discount: 0 });
36
+ ctx.patch('cart', { discount: 0.1 });
37
+ assert.deepEqual(ctx.getState('cart'), { items: 2, total: 47, discount: 0.1 });
38
+ });
39
+
40
+ test('setState replaces, patch keeps the rest', () => {
41
+ const ctx = freshContext('s', { a: 1, b: 2 });
42
+ ctx.setState('s', { a: 9 }); // replace → drops b
43
+ assert.deepEqual(ctx.getState('s'), { a: 9 });
44
+ ctx.patch('s', { b: 5 }); // merge onto { a: 9 }
45
+ assert.deepEqual(ctx.getState('s'), { a: 9, b: 5 });
46
+ });
47
+
48
+ test('patch rejects non-object partials and missing contexts (no throw)', () => {
49
+ const ctx = freshContext('s', { a: 1 });
50
+ ctx.patch('s', null);
51
+ ctx.patch('s', [1, 2]);
52
+ ctx.patch('nope', { x: 1 });
53
+ assert.deepEqual(ctx.getState('s'), { a: 1 });
54
+ assert.equal(ctx.has('nope'), false);
55
+ });
56
+
57
+ test('use(name) returns a handle bound to that context', () => {
58
+ const ctx = freshContext('settings', { model: 'a' });
59
+ const settings = ctx.use('settings');
60
+
61
+ assert.equal(settings.get().model, 'a');
62
+ settings.set({ model: 'b' });
63
+ assert.equal(ctx.getState('settings').model, 'b');
64
+ settings.patch({ theme: 'dark' });
65
+ assert.deepEqual(ctx.getState('settings'), { model: 'b', theme: 'dark' });
66
+ assert.equal(settings.has(), true);
67
+ settings.destroy();
68
+ assert.equal(ctx.has('settings'), false);
69
+ });
70
+
71
+ test('use(name).bind calls back immediately and on every change', () => {
72
+ const ctx = freshContext('cart', { items: [], n: 0 });
73
+ const calls = [];
74
+ ctx.use('cart').bind({ sliceId: 'c1' }, (state) => calls.push(state.n));
75
+
76
+ assert.deepEqual(calls, [0]); // immediate initial call
77
+ ctx.patch('cart', { n: 1 });
78
+ assert.deepEqual(calls, [0, 1]); // fired on change
79
+ });
80
+
81
+ test('use(name).bind with a selector only fires when the selected value changes', () => {
82
+ const ctx = freshContext('cart', { items: ['x'], note: 'a' });
83
+ const counts = [];
84
+ ctx.use('cart').bind(
85
+ { sliceId: 'c2' },
86
+ (count) => counts.push(count),
87
+ (s) => s.items.length
88
+ );
89
+
90
+ assert.deepEqual(counts, [1]); // immediate, selected value = length
91
+ ctx.patch('cart', { note: 'b' }); // length unchanged → no fire
92
+ assert.deepEqual(counts, [1]);
93
+ ctx.patch('cart', { items: ['x', 'y'] }); // length changed → fire
94
+ assert.deepEqual(counts, [1, 2]);
95
+ });
@@ -1,215 +1,215 @@
1
- // Real-Controller tests for destroyComponent cascading to children.
2
- //
3
- // Loads the REAL Controller (via the resolve hook that stubs its
4
- // '/Components/components.js' import) and drives it with a minimal fake DOM, so
5
- // the actual registerComponentsRecursively + destroyComponent code runs. This
6
- // reproduces the childrenIndex bug and verifies the fix: destroyComponent(parent)
7
- // must cascade to nested Visual children, running their beforeDestroy.
8
- import { register } from 'node:module';
9
- import test from 'node:test';
10
- import assert from 'node:assert/strict';
11
-
12
- globalThis.alert = () => {};
13
- register(new URL('./fixtures/real-runtime-loader.mjs', import.meta.url));
14
-
15
- // The controller references the `slice` global for logging / events.
16
- globalThis.slice = {
17
- logger: { logError() {}, logWarning() {}, logInfo() {} },
18
- events: null
19
- };
20
-
21
- const { default: Controller } = await import('../Components/Structural/Controller/Controller.js');
22
-
23
- // --- minimal fake DOM ----------------------------------------------------
24
- // Each node is a SLICE-* element with querySelectorAll('*') returning all of
25
- // its descendants in document (pre) order — enough for registerComponentsRecursively.
26
- let destroyLog = [];
27
-
28
- function mkEl(sliceId, children = []) {
29
- const node = {
30
- sliceId,
31
- tagName: 'SLICE-X',
32
- isConnected: true,
33
- parentComponent: null,
34
- _children: children,
35
- querySelectorAll() {
36
- const out = [];
37
- const walk = (n) => {
38
- for (const c of n._children) {
39
- out.push(c);
40
- walk(c);
41
- }
42
- };
43
- walk(node);
44
- return out;
45
- },
46
- remove() {
47
- this.isConnected = false;
48
- },
49
- beforeDestroy() {
50
- destroyLog.push(this.sliceId);
51
- }
52
- };
53
- return node;
54
- }
55
-
56
- // Collect every node in a tree (pre-order).
57
- function flatten(root) {
58
- const out = [root];
59
- for (const c of root._children) out.push(...flatten(c));
60
- return out;
61
- }
62
-
63
- // Simulate slice.build's registration: registerComponent WITHOUT a parent for
64
- // every node, then registerComponentsRecursively on the root (what _build does
65
- // for a Visual once its subtree is in the DOM).
66
- function buildTree(controller, root) {
67
- for (const node of flatten(root)) controller.registerComponent(node);
68
- controller.registerComponentsRecursively(root);
69
- }
70
-
71
- function setup() {
72
- destroyLog = [];
73
- return new Controller();
74
- }
75
-
76
- test('destroyComponent(parent) cascades to a nested child', () => {
77
- const c = setup();
78
- const child = mkEl('child');
79
- const parent = mkEl('parent', [child]);
80
- buildTree(c, parent);
81
-
82
- assert.equal(c.activeComponents.has('child'), true, 'child registered');
83
-
84
- const count = c.destroyComponent(parent);
85
-
86
- assert.equal(c.activeComponents.has('parent'), false, 'parent removed');
87
- assert.equal(c.activeComponents.has('child'), false, 'child removed (cascaded)');
88
- assert.ok(destroyLog.includes('child'), 'child beforeDestroy ran');
89
- assert.equal(count, 2, 'reported 2 destroyed');
90
- });
91
-
92
- test('cascade reaches deep descendants, deepest beforeDestroy first', () => {
93
- const c = setup();
94
- const grandchild = mkEl('grandchild');
95
- const child = mkEl('child', [grandchild]);
96
- const parent = mkEl('parent', [child]);
97
- buildTree(c, parent);
98
-
99
- c.destroyComponent(parent);
100
-
101
- assert.equal(c.activeComponents.size, 0, 'all three destroyed');
102
- // deepest-first ordering (children before their parents)
103
- assert.ok(
104
- destroyLog.indexOf('grandchild') < destroyLog.indexOf('child'),
105
- 'grandchild before child'
106
- );
107
- assert.ok(destroyLog.indexOf('child') < destroyLog.indexOf('parent'), 'child before parent');
108
- });
109
-
110
- test('childrenIndex records DIRECT parents, not ancestors', () => {
111
- const c = setup();
112
- const grandchild = mkEl('grandchild');
113
- const child = mkEl('child', [grandchild]);
114
- const parent = mkEl('parent', [child]);
115
- buildTree(c, parent);
116
-
117
- assert.deepEqual([...(c.childrenIndex.get('parent') || [])], ['child'], 'parent -> child');
118
- assert.deepEqual([...(c.childrenIndex.get('child') || [])], ['grandchild'], 'child -> grandchild');
119
- assert.equal(child.parentComponent.sliceId, 'parent');
120
- assert.equal(grandchild.parentComponent.sliceId, 'child', 'direct parent, not the root');
121
- });
122
-
123
- test('destroying a subtree leaves no stale childrenIndex entries', () => {
124
- const c = setup();
125
- const child = mkEl('child');
126
- const parent = mkEl('parent', [child]);
127
- buildTree(c, parent);
128
-
129
- c.destroyComponent(parent);
130
-
131
- assert.equal(c.childrenIndex.has('parent'), false, 'parent index entry cleared');
132
- assert.equal(c.childrenIndex.has('child'), false, 'child index entry cleared');
133
- });
134
-
135
- test('a Service (no DOM element) is NOT cascaded — still needs explicit destroy', () => {
136
- const c = setup();
137
- const child = mkEl('child');
138
- const parent = mkEl('parent', [child]);
139
- buildTree(c, parent);
140
- // A Service built by the parent: registered, but not part of any DOM subtree.
141
- const service = { sliceId: 'svc', isConnected: false, beforeDestroy() { destroyLog.push('svc'); } };
142
- c.registerComponent(service);
143
-
144
- c.destroyComponent(parent);
145
-
146
- assert.equal(c.activeComponents.has('svc'), true, 'service survives parent destroy');
147
- assert.equal(destroyLog.includes('svc'), false, 'service beforeDestroy did NOT run');
148
- });
149
-
150
- test('all siblings under one parent cascade', () => {
151
- const c = setup();
152
- const a = mkEl('a');
153
- const b = mkEl('b');
154
- const parent = mkEl('parent', [a, b]);
155
- buildTree(c, parent);
156
-
157
- assert.deepEqual([...c.childrenIndex.get('parent')].sort(), ['a', 'b']);
158
-
159
- c.destroyComponent(parent);
160
- assert.equal(c.activeComponents.size, 0, 'parent and both siblings destroyed');
161
- });
162
-
163
- test('destroying a mid-level child destroys only its subtree', () => {
164
- const c = setup();
165
- const grandchild = mkEl('grandchild');
166
- const child = mkEl('child', [grandchild]);
167
- const parent = mkEl('parent', [child]);
168
- buildTree(c, parent);
169
-
170
- const count = c.destroyComponent(child);
171
-
172
- assert.equal(count, 2, 'child + grandchild');
173
- assert.equal(c.activeComponents.has('parent'), true, 'parent survives');
174
- assert.equal(c.activeComponents.has('child'), false);
175
- assert.equal(c.activeComponents.has('grandchild'), false);
176
- assert.equal(
177
- c.childrenIndex.has('parent') && c.childrenIndex.get('parent').has('child'),
178
- false,
179
- "parent's index no longer points to the destroyed child"
180
- );
181
- });
182
-
183
- test('re-running registerComponentsRecursively is idempotent', () => {
184
- const c = setup();
185
- const child = mkEl('child');
186
- const parent = mkEl('parent', [child]);
187
- buildTree(c, parent);
188
- c.registerComponentsRecursively(parent); // walk again
189
-
190
- assert.deepEqual([...c.childrenIndex.get('parent')], ['child'], 'no duplicate edges');
191
-
192
- c.destroyComponent(parent);
193
- assert.equal(c.activeComponents.size, 0, 'still cascades cleanly');
194
- });
195
-
196
- test('destroyByContainer also collects the whole DOM subtree', () => {
197
- const c = setup();
198
- const grandchild = mkEl('grandchild');
199
- const child = mkEl('child', [grandchild]);
200
- const parent = mkEl('parent', [child]);
201
- buildTree(c, parent);
202
-
203
- // destroyByContainer queries [slice-id] on the container; emulate that query.
204
- const container = {
205
- querySelectorAll() {
206
- return flatten(parent).map((n) => ({
207
- getAttribute: () => n.sliceId,
208
- sliceId: n.sliceId
209
- }));
210
- }
211
- };
212
-
213
- const count = c.destroyByContainer(container);
214
- assert.equal(count, 3, 'all three destroyed via DOM discovery');
215
- });
1
+ // Real-Controller tests for destroyComponent cascading to children.
2
+ //
3
+ // Loads the REAL Controller (via the resolve hook that stubs its
4
+ // '/Components/components.js' import) and drives it with a minimal fake DOM, so
5
+ // the actual registerComponentsRecursively + destroyComponent code runs. This
6
+ // reproduces the childrenIndex bug and verifies the fix: destroyComponent(parent)
7
+ // must cascade to nested Visual children, running their beforeDestroy.
8
+ import { register } from 'node:module';
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ globalThis.alert = () => {};
13
+ register(new URL('./fixtures/real-runtime-loader.mjs', import.meta.url));
14
+
15
+ // The controller references the `slice` global for logging / events.
16
+ globalThis.slice = {
17
+ logger: { logError() {}, logWarning() {}, logInfo() {} },
18
+ events: null
19
+ };
20
+
21
+ const { default: Controller } = await import('../Components/Structural/Controller/Controller.js');
22
+
23
+ // --- minimal fake DOM ----------------------------------------------------
24
+ // Each node is a SLICE-* element with querySelectorAll('*') returning all of
25
+ // its descendants in document (pre) order — enough for registerComponentsRecursively.
26
+ let destroyLog = [];
27
+
28
+ function mkEl(sliceId, children = []) {
29
+ const node = {
30
+ sliceId,
31
+ tagName: 'SLICE-X',
32
+ isConnected: true,
33
+ parentComponent: null,
34
+ _children: children,
35
+ querySelectorAll() {
36
+ const out = [];
37
+ const walk = (n) => {
38
+ for (const c of n._children) {
39
+ out.push(c);
40
+ walk(c);
41
+ }
42
+ };
43
+ walk(node);
44
+ return out;
45
+ },
46
+ remove() {
47
+ this.isConnected = false;
48
+ },
49
+ beforeDestroy() {
50
+ destroyLog.push(this.sliceId);
51
+ }
52
+ };
53
+ return node;
54
+ }
55
+
56
+ // Collect every node in a tree (pre-order).
57
+ function flatten(root) {
58
+ const out = [root];
59
+ for (const c of root._children) out.push(...flatten(c));
60
+ return out;
61
+ }
62
+
63
+ // Simulate slice.build's registration: registerComponent WITHOUT a parent for
64
+ // every node, then registerComponentsRecursively on the root (what _build does
65
+ // for a Visual once its subtree is in the DOM).
66
+ function buildTree(controller, root) {
67
+ for (const node of flatten(root)) controller.registerComponent(node);
68
+ controller.registerComponentsRecursively(root);
69
+ }
70
+
71
+ function setup() {
72
+ destroyLog = [];
73
+ return new Controller();
74
+ }
75
+
76
+ test('destroyComponent(parent) cascades to a nested child', () => {
77
+ const c = setup();
78
+ const child = mkEl('child');
79
+ const parent = mkEl('parent', [child]);
80
+ buildTree(c, parent);
81
+
82
+ assert.equal(c.activeComponents.has('child'), true, 'child registered');
83
+
84
+ const count = c.destroyComponent(parent);
85
+
86
+ assert.equal(c.activeComponents.has('parent'), false, 'parent removed');
87
+ assert.equal(c.activeComponents.has('child'), false, 'child removed (cascaded)');
88
+ assert.ok(destroyLog.includes('child'), 'child beforeDestroy ran');
89
+ assert.equal(count, 2, 'reported 2 destroyed');
90
+ });
91
+
92
+ test('cascade reaches deep descendants, deepest beforeDestroy first', () => {
93
+ const c = setup();
94
+ const grandchild = mkEl('grandchild');
95
+ const child = mkEl('child', [grandchild]);
96
+ const parent = mkEl('parent', [child]);
97
+ buildTree(c, parent);
98
+
99
+ c.destroyComponent(parent);
100
+
101
+ assert.equal(c.activeComponents.size, 0, 'all three destroyed');
102
+ // deepest-first ordering (children before their parents)
103
+ assert.ok(
104
+ destroyLog.indexOf('grandchild') < destroyLog.indexOf('child'),
105
+ 'grandchild before child'
106
+ );
107
+ assert.ok(destroyLog.indexOf('child') < destroyLog.indexOf('parent'), 'child before parent');
108
+ });
109
+
110
+ test('childrenIndex records DIRECT parents, not ancestors', () => {
111
+ const c = setup();
112
+ const grandchild = mkEl('grandchild');
113
+ const child = mkEl('child', [grandchild]);
114
+ const parent = mkEl('parent', [child]);
115
+ buildTree(c, parent);
116
+
117
+ assert.deepEqual([...(c.childrenIndex.get('parent') || [])], ['child'], 'parent -> child');
118
+ assert.deepEqual([...(c.childrenIndex.get('child') || [])], ['grandchild'], 'child -> grandchild');
119
+ assert.equal(child.parentComponent.sliceId, 'parent');
120
+ assert.equal(grandchild.parentComponent.sliceId, 'child', 'direct parent, not the root');
121
+ });
122
+
123
+ test('destroying a subtree leaves no stale childrenIndex entries', () => {
124
+ const c = setup();
125
+ const child = mkEl('child');
126
+ const parent = mkEl('parent', [child]);
127
+ buildTree(c, parent);
128
+
129
+ c.destroyComponent(parent);
130
+
131
+ assert.equal(c.childrenIndex.has('parent'), false, 'parent index entry cleared');
132
+ assert.equal(c.childrenIndex.has('child'), false, 'child index entry cleared');
133
+ });
134
+
135
+ test('a Service (no DOM element) is NOT cascaded — still needs explicit destroy', () => {
136
+ const c = setup();
137
+ const child = mkEl('child');
138
+ const parent = mkEl('parent', [child]);
139
+ buildTree(c, parent);
140
+ // A Service built by the parent: registered, but not part of any DOM subtree.
141
+ const service = { sliceId: 'svc', isConnected: false, beforeDestroy() { destroyLog.push('svc'); } };
142
+ c.registerComponent(service);
143
+
144
+ c.destroyComponent(parent);
145
+
146
+ assert.equal(c.activeComponents.has('svc'), true, 'service survives parent destroy');
147
+ assert.equal(destroyLog.includes('svc'), false, 'service beforeDestroy did NOT run');
148
+ });
149
+
150
+ test('all siblings under one parent cascade', () => {
151
+ const c = setup();
152
+ const a = mkEl('a');
153
+ const b = mkEl('b');
154
+ const parent = mkEl('parent', [a, b]);
155
+ buildTree(c, parent);
156
+
157
+ assert.deepEqual([...c.childrenIndex.get('parent')].sort(), ['a', 'b']);
158
+
159
+ c.destroyComponent(parent);
160
+ assert.equal(c.activeComponents.size, 0, 'parent and both siblings destroyed');
161
+ });
162
+
163
+ test('destroying a mid-level child destroys only its subtree', () => {
164
+ const c = setup();
165
+ const grandchild = mkEl('grandchild');
166
+ const child = mkEl('child', [grandchild]);
167
+ const parent = mkEl('parent', [child]);
168
+ buildTree(c, parent);
169
+
170
+ const count = c.destroyComponent(child);
171
+
172
+ assert.equal(count, 2, 'child + grandchild');
173
+ assert.equal(c.activeComponents.has('parent'), true, 'parent survives');
174
+ assert.equal(c.activeComponents.has('child'), false);
175
+ assert.equal(c.activeComponents.has('grandchild'), false);
176
+ assert.equal(
177
+ c.childrenIndex.has('parent') && c.childrenIndex.get('parent').has('child'),
178
+ false,
179
+ "parent's index no longer points to the destroyed child"
180
+ );
181
+ });
182
+
183
+ test('re-running registerComponentsRecursively is idempotent', () => {
184
+ const c = setup();
185
+ const child = mkEl('child');
186
+ const parent = mkEl('parent', [child]);
187
+ buildTree(c, parent);
188
+ c.registerComponentsRecursively(parent); // walk again
189
+
190
+ assert.deepEqual([...c.childrenIndex.get('parent')], ['child'], 'no duplicate edges');
191
+
192
+ c.destroyComponent(parent);
193
+ assert.equal(c.activeComponents.size, 0, 'still cascades cleanly');
194
+ });
195
+
196
+ test('destroyByContainer also collects the whole DOM subtree', () => {
197
+ const c = setup();
198
+ const grandchild = mkEl('grandchild');
199
+ const child = mkEl('child', [grandchild]);
200
+ const parent = mkEl('parent', [child]);
201
+ buildTree(c, parent);
202
+
203
+ // destroyByContainer queries [slice-id] on the container; emulate that query.
204
+ const container = {
205
+ querySelectorAll() {
206
+ return flatten(parent).map((n) => ({
207
+ getAttribute: () => n.sliceId,
208
+ sliceId: n.sliceId
209
+ }));
210
+ }
211
+ };
212
+
213
+ const count = c.destroyByContainer(container);
214
+ assert.equal(count, 3, 'all three destroyed via DOM discovery');
215
+ });