slicejs-web-framework 3.3.4 → 3.3.6
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.
- package/.opencode/opencode.json +1 -0
- package/Slice/Components/Structural/Controller/Controller.js +52 -0
- package/Slice/Slice.js +47 -0
- package/Slice/tests/build-singleton.test.js +221 -0
- package/Slice/tests/destroy-cascade.test.js +215 -0
- package/Slice/tests/fixtures/real-runtime-loader.mjs +19 -0
- package/package.json +1 -1
package/.opencode/opencode.json
CHANGED
|
@@ -20,6 +20,11 @@ export default class Controller {
|
|
|
20
20
|
this.bundleImportPromises = new Map();
|
|
21
21
|
this.bundleLoadPromises = new Map();
|
|
22
22
|
|
|
23
|
+
// 🔁 Singleton builds in flight: sliceId → Promise<instance>.
|
|
24
|
+
// Lets concurrent build({singleton:true}) calls share one build instead
|
|
25
|
+
// of racing on a duplicate sliceId. Entries are transient (deleted on settle).
|
|
26
|
+
this._pendingBuilds = new Map();
|
|
27
|
+
|
|
23
28
|
this.idCounter = 0;
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -711,6 +716,11 @@ export default class Controller {
|
|
|
711
716
|
// Registrar en activeComponents
|
|
712
717
|
this.activeComponents.set(component.sliceId, component);
|
|
713
718
|
|
|
719
|
+
// Exponer sliceId como atributo HTML para búsqueda por DOM (destroyByContainer, etc.)
|
|
720
|
+
if (typeof component.setAttribute === 'function') {
|
|
721
|
+
component.setAttribute('slice-id', component.sliceId);
|
|
722
|
+
}
|
|
723
|
+
|
|
714
724
|
// 🚀 OPTIMIZACIÓN: Actualizar índice inverso de hijos
|
|
715
725
|
if (parent) {
|
|
716
726
|
if (!this.childrenIndex.has(parent.sliceId)) {
|
|
@@ -731,8 +741,23 @@ export default class Controller {
|
|
|
731
741
|
// Recursively assign parent to children
|
|
732
742
|
component.querySelectorAll('*').forEach((child) => {
|
|
733
743
|
if (child.tagName.startsWith('SLICE-')) {
|
|
744
|
+
// Only the call that establishes the DIRECT parent link feeds the
|
|
745
|
+
// index — the depth-first recursion sets a node's parent before an
|
|
746
|
+
// outer ancestor's forEach reaches it, so `component` here is the
|
|
747
|
+
// immediate enclosing component.
|
|
734
748
|
if (!child.parentComponent) {
|
|
735
749
|
child.parentComponent = component;
|
|
750
|
+
// 🔁 Maintain childrenIndex so destroyComponent(parent) cascades
|
|
751
|
+
// to children built via slice.build (which registers them WITHOUT
|
|
752
|
+
// a parent, leaving the index otherwise empty). Without this, a
|
|
753
|
+
// parent's destroy never finds — and never cleans up — its children.
|
|
754
|
+
if (child.sliceId && component.sliceId) {
|
|
755
|
+
if (!this.childrenIndex.has(component.sliceId)) {
|
|
756
|
+
this.childrenIndex.set(component.sliceId, new Set());
|
|
757
|
+
}
|
|
758
|
+
this.childrenIndex.get(component.sliceId).add(child.sliceId);
|
|
759
|
+
child._depth = (component._depth || 0) + 1;
|
|
760
|
+
}
|
|
736
761
|
}
|
|
737
762
|
this.registerComponentsRecursively(child, component);
|
|
738
763
|
}
|
|
@@ -748,6 +773,33 @@ export default class Controller {
|
|
|
748
773
|
return this.activeComponents.get(sliceId);
|
|
749
774
|
}
|
|
750
775
|
|
|
776
|
+
/**
|
|
777
|
+
* Get-or-create a single instance keyed by sliceId, deduplicating concurrent
|
|
778
|
+
* builds. Returns the existing instance if already registered, the in-flight
|
|
779
|
+
* promise if a build is underway, or otherwise memoizes a fresh build via
|
|
780
|
+
* `builder` (an injected closure — the controller never builds by itself).
|
|
781
|
+
*
|
|
782
|
+
* The in-flight promise is removed once it settles, so a failed build (which
|
|
783
|
+
* resolves to `null` and never registers in activeComponents) can be retried
|
|
784
|
+
* by a later call and never poisons the registry.
|
|
785
|
+
*
|
|
786
|
+
* @param {string} sliceId
|
|
787
|
+
* @param {() => Promise<any>} builder
|
|
788
|
+
* @returns {any|Promise<any>} instance (sync) or Promise<instance>
|
|
789
|
+
*/
|
|
790
|
+
getOrCreate(sliceId, builder) {
|
|
791
|
+
const existing = this.activeComponents.get(sliceId);
|
|
792
|
+
if (existing) return existing;
|
|
793
|
+
|
|
794
|
+
const pending = this._pendingBuilds.get(sliceId);
|
|
795
|
+
if (pending) return pending;
|
|
796
|
+
|
|
797
|
+
const promise = Promise.resolve(builder())
|
|
798
|
+
.finally(() => this._pendingBuilds.delete(sliceId));
|
|
799
|
+
this._pendingBuilds.set(sliceId, promise);
|
|
800
|
+
return promise;
|
|
801
|
+
}
|
|
802
|
+
|
|
751
803
|
loadTemplateToComponent(component) {
|
|
752
804
|
const className = component.constructor.name;
|
|
753
805
|
const template = this.templates.get(className);
|
package/Slice/Slice.js
CHANGED
|
@@ -90,11 +90,54 @@ export default class Slice {
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* Build a component instance and run init.
|
|
93
|
+
*
|
|
94
|
+
* Pass `{ singleton: true }` to get-or-create one shared instance keyed by
|
|
95
|
+
* `sliceId` (defaults to `componentName`). Concurrent singleton builds of the
|
|
96
|
+
* same id share a single in-flight build, so they never race on a duplicate
|
|
97
|
+
* sliceId. Singletons are only supported for Service components — for app-wide
|
|
98
|
+
* UI build a Provider Service that manages the Visual (see ToastProvider /
|
|
99
|
+
* ToolTipProvider), because a DOM node can only live in one place.
|
|
100
|
+
*
|
|
101
|
+
* Note: `props` only apply on the first (creating) call; later calls return
|
|
102
|
+
* the existing instance and ignore them.
|
|
103
|
+
*
|
|
93
104
|
* @param {string} componentName
|
|
94
105
|
* @param {Object} [props]
|
|
106
|
+
* @param {boolean} [props.singleton] Reuse a single instance per sliceId.
|
|
95
107
|
* @returns {Promise<HTMLElement|Object|null>}
|
|
96
108
|
*/
|
|
97
109
|
async build(componentName, props = {}) {
|
|
110
|
+
if (!props || props.singleton !== true) {
|
|
111
|
+
return this._build(componentName, props);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { singleton, ...rest } = props;
|
|
115
|
+
const sliceId = rest.sliceId || componentName;
|
|
116
|
+
|
|
117
|
+
const category = this.controller.componentCategories.get(componentName);
|
|
118
|
+
if (category !== 'Service') {
|
|
119
|
+
this.logger.logError(
|
|
120
|
+
'Slice',
|
|
121
|
+
`singleton:true is only supported for Service components ('${componentName}' is ${category || 'unknown'}). ` +
|
|
122
|
+
`For app-wide UI build a Provider Service that manages the Visual (see ToastProvider/ToolTipProvider).`
|
|
123
|
+
);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return this.controller.getOrCreate(sliceId, () =>
|
|
128
|
+
this._build(componentName, { ...rest, sliceId })
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Internal build: load resources, construct, run init, register. Always
|
|
134
|
+
* creates a new instance. Public `build` delegates here (and wraps it with
|
|
135
|
+
* get-or-create when `singleton:true`).
|
|
136
|
+
* @param {string} componentName
|
|
137
|
+
* @param {Object} [props]
|
|
138
|
+
* @returns {Promise<HTMLElement|Object|null>}
|
|
139
|
+
*/
|
|
140
|
+
async _build(componentName, props = {}) {
|
|
98
141
|
if (!componentName) {
|
|
99
142
|
this.logger.logError('Slice', null, `Component name is required to build a component`);
|
|
100
143
|
return null;
|
|
@@ -193,6 +236,10 @@ export default class Slice {
|
|
|
193
236
|
|
|
194
237
|
delete props.id;
|
|
195
238
|
delete props.sliceId;
|
|
239
|
+
// `singleton` is a build directive (handled in the public build()
|
|
240
|
+
// wrapper). Strip it here too so it is consistently reserved and never
|
|
241
|
+
// leaks into a component's props on the non-singleton path.
|
|
242
|
+
delete props.singleton;
|
|
196
243
|
|
|
197
244
|
const ComponentClass = this.controller.classes.get(componentName);
|
|
198
245
|
this.logger.logInfo(
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Real-runtime integration tests for build({ singleton: true }).
|
|
2
|
+
//
|
|
3
|
+
// These load the REAL Slice + REAL Controller (no FakeController): the only
|
|
4
|
+
// fixture is the components map, injected via a resolve hook because Controller
|
|
5
|
+
// imports a browser-absolute '/Components/components.js'. Singletons are
|
|
6
|
+
// Service-only (no DOM), so the full build → getOrCreate → _build →
|
|
7
|
+
// registerComponent path runs end-to-end under node, exercising the actual
|
|
8
|
+
// shipped code.
|
|
9
|
+
import { register } from 'node:module';
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
|
|
13
|
+
globalThis.alert = () => {};
|
|
14
|
+
register(new URL('./fixtures/real-runtime-loader.mjs', import.meta.url));
|
|
15
|
+
|
|
16
|
+
const { default: Slice } = await import('../Slice.js');
|
|
17
|
+
const { default: Controller } = await import('../Components/Structural/Controller/Controller.js');
|
|
18
|
+
|
|
19
|
+
// A real Service-shaped class. The components fixture registers 'Probe' as a
|
|
20
|
+
// Service and 'ModalProbe' as a Visual.
|
|
21
|
+
let constructCount = 0;
|
|
22
|
+
let failNext = false;
|
|
23
|
+
|
|
24
|
+
class Probe {
|
|
25
|
+
constructor(props) {
|
|
26
|
+
constructCount += 1;
|
|
27
|
+
if (failNext) {
|
|
28
|
+
failNext = false;
|
|
29
|
+
throw new Error('boom'); // simulate a failed build on first attempt
|
|
30
|
+
}
|
|
31
|
+
this.props = props;
|
|
32
|
+
}
|
|
33
|
+
async init() {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class FakeStylesManager {
|
|
37
|
+
registerComponentStyles() {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createSlice() {
|
|
41
|
+
const instance = new Slice(
|
|
42
|
+
{
|
|
43
|
+
paths: {
|
|
44
|
+
components: {
|
|
45
|
+
Service: { path: '/Components/Service', type: 'Service' },
|
|
46
|
+
Visual: { path: '/Components/Visual', type: 'Visual' }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
themeManager: {},
|
|
50
|
+
stylesManager: {},
|
|
51
|
+
logger: {},
|
|
52
|
+
debugger: { enabled: false },
|
|
53
|
+
loading: {},
|
|
54
|
+
events: {}
|
|
55
|
+
},
|
|
56
|
+
{ Controller, StylesManager: FakeStylesManager }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
instance.logger = { logError() {}, logWarning() {}, logInfo() {} };
|
|
60
|
+
// Pre-seed the class so _build skips the network fetch (the real path for a
|
|
61
|
+
// class already cached by the controller). Everything else is real.
|
|
62
|
+
instance.controller.classes.set('Probe', Probe);
|
|
63
|
+
return instance;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function withSlice(fn) {
|
|
67
|
+
return async () => {
|
|
68
|
+
const original = globalThis.slice;
|
|
69
|
+
constructCount = 0;
|
|
70
|
+
failNext = false;
|
|
71
|
+
const slice = createSlice();
|
|
72
|
+
globalThis.slice = slice;
|
|
73
|
+
try {
|
|
74
|
+
await fn(slice);
|
|
75
|
+
} finally {
|
|
76
|
+
globalThis.slice = original;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test(
|
|
82
|
+
'singleton returns the same instance across awaited calls and builds once',
|
|
83
|
+
withSlice(async (slice) => {
|
|
84
|
+
const a = await slice.build('Probe', { singleton: true });
|
|
85
|
+
const b = await slice.build('Probe', { singleton: true });
|
|
86
|
+
|
|
87
|
+
assert.ok(a, 'first call returns an instance');
|
|
88
|
+
assert.equal(a, b, 'same instance reference');
|
|
89
|
+
assert.equal(constructCount, 1, 'constructed exactly once');
|
|
90
|
+
assert.equal(a.sliceId, 'Probe', 'sliceId defaults to component name');
|
|
91
|
+
assert.equal(slice.controller.activeComponents.get('Probe'), a, 'registered in activeComponents');
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
test(
|
|
96
|
+
'concurrent singleton builds share one in-flight build (race-safe)',
|
|
97
|
+
withSlice(async (slice) => {
|
|
98
|
+
const [a, b] = await Promise.all([
|
|
99
|
+
slice.build('Probe', { singleton: true }),
|
|
100
|
+
slice.build('Probe', { singleton: true })
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
assert.equal(a, b, 'both callers get the same instance');
|
|
104
|
+
assert.equal(constructCount, 1, 'deduped to a single construction');
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
test(
|
|
109
|
+
'named singletons via distinct sliceId are separate instances',
|
|
110
|
+
withSlice(async (slice) => {
|
|
111
|
+
const a = await slice.build('Probe', { singleton: true, sliceId: 'probeA' });
|
|
112
|
+
const b = await slice.build('Probe', { singleton: true, sliceId: 'probeB' });
|
|
113
|
+
|
|
114
|
+
assert.notEqual(a, b);
|
|
115
|
+
assert.equal(a.sliceId, 'probeA');
|
|
116
|
+
assert.equal(b.sliceId, 'probeB');
|
|
117
|
+
assert.equal(constructCount, 2);
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
test(
|
|
122
|
+
'a failed singleton build is not cached and the next call retries',
|
|
123
|
+
withSlice(async (slice) => {
|
|
124
|
+
failNext = true;
|
|
125
|
+
const first = await slice.build('Probe', { singleton: true });
|
|
126
|
+
assert.equal(first, null, 'failed build resolves to null');
|
|
127
|
+
assert.equal(
|
|
128
|
+
slice.controller._pendingBuilds.has('Probe'),
|
|
129
|
+
false,
|
|
130
|
+
'pending entry cleared after settle'
|
|
131
|
+
);
|
|
132
|
+
assert.equal(
|
|
133
|
+
slice.controller.activeComponents.has('Probe'),
|
|
134
|
+
false,
|
|
135
|
+
'failed build never registered'
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const second = await slice.build('Probe', { singleton: true });
|
|
139
|
+
assert.ok(second, 'retry succeeds');
|
|
140
|
+
assert.equal(constructCount, 2, 'retried construction');
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
test(
|
|
145
|
+
'singleton:true is rejected for non-Service components',
|
|
146
|
+
withSlice(async (slice) => {
|
|
147
|
+
let errored = false;
|
|
148
|
+
slice.logger.logError = () => {
|
|
149
|
+
errored = true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const result = await slice.build('ModalProbe', { singleton: true });
|
|
153
|
+
|
|
154
|
+
assert.equal(result, null, 'returns null for a Visual');
|
|
155
|
+
assert.equal(errored, true, 'logs an error');
|
|
156
|
+
assert.equal(constructCount, 0, 'never constructed');
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
test(
|
|
161
|
+
'default (non-singleton) build path is unchanged',
|
|
162
|
+
withSlice(async (slice) => {
|
|
163
|
+
const a = await slice.build('Probe');
|
|
164
|
+
const b = await slice.build('Probe');
|
|
165
|
+
|
|
166
|
+
assert.ok(a);
|
|
167
|
+
assert.ok(b);
|
|
168
|
+
assert.notEqual(a, b, 'each non-singleton build is a fresh instance');
|
|
169
|
+
assert.equal(constructCount, 2);
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
test(
|
|
174
|
+
'props only apply on the first (creating) call',
|
|
175
|
+
withSlice(async (slice) => {
|
|
176
|
+
const a = await slice.build('Probe', { singleton: true, tag: 'first' });
|
|
177
|
+
const b = await slice.build('Probe', { singleton: true, tag: 'second' });
|
|
178
|
+
|
|
179
|
+
assert.equal(a, b, 'same instance');
|
|
180
|
+
assert.equal(constructCount, 1, 'not rebuilt');
|
|
181
|
+
assert.equal(a.props.tag, 'first', 'later props are ignored');
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
test(
|
|
186
|
+
'the singleton/sliceId directives are not forwarded to the component props',
|
|
187
|
+
withSlice(async (slice) => {
|
|
188
|
+
const a = await slice.build('Probe', { singleton: true, sliceId: 'probeX', tag: 'keep' });
|
|
189
|
+
|
|
190
|
+
assert.equal(a.props.tag, 'keep', 'real props pass through');
|
|
191
|
+
assert.equal('singleton' in a.props, false, 'singleton flag stripped');
|
|
192
|
+
assert.equal('sliceId' in a.props, false, 'sliceId directive stripped');
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
test(
|
|
197
|
+
'singleton is stripped from props even on a non-singleton build',
|
|
198
|
+
withSlice(async (slice) => {
|
|
199
|
+
const a = await slice.build('Probe', { singleton: false, tag: 'keep' });
|
|
200
|
+
|
|
201
|
+
assert.ok(a);
|
|
202
|
+
assert.equal(a.props.tag, 'keep', 'real props pass through');
|
|
203
|
+
assert.equal('singleton' in a.props, false, 'singleton key never reaches the component');
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
test(
|
|
208
|
+
'singleton:true on an unregistered component is rejected (unknown category)',
|
|
209
|
+
withSlice(async (slice) => {
|
|
210
|
+
let errored = false;
|
|
211
|
+
slice.logger.logError = () => {
|
|
212
|
+
errored = true;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const result = await slice.build('NotRegistered', { singleton: true });
|
|
216
|
+
|
|
217
|
+
assert.equal(result, null, 'returns null');
|
|
218
|
+
assert.equal(errored, true, 'logs an error');
|
|
219
|
+
assert.equal(constructCount, 0, 'never constructed');
|
|
220
|
+
})
|
|
221
|
+
);
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Node ESM resolve hook so the REAL Controller can load under `node --test`.
|
|
2
|
+
//
|
|
3
|
+
// Controller.js does `import components from '/Components/components.js'` — a
|
|
4
|
+
// browser-absolute path that the dev server serves but node can't resolve. We
|
|
5
|
+
// map only that exact specifier to a small fixture components map (just data,
|
|
6
|
+
// the same shape the real components.js exports). Everything else resolves
|
|
7
|
+
// normally, so the tests exercise the real Slice + Controller code, not mocks.
|
|
8
|
+
const COMPONENTS = { Probe: 'Service', ModalProbe: 'Visual' };
|
|
9
|
+
const STUB = `export default ${JSON.stringify(COMPONENTS)};`;
|
|
10
|
+
|
|
11
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
12
|
+
if (specifier === '/Components/components.js') {
|
|
13
|
+
return {
|
|
14
|
+
url: `data:text/javascript,${encodeURIComponent(STUB)}`,
|
|
15
|
+
shortCircuit: true
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return nextResolve(specifier, context);
|
|
19
|
+
}
|