slicejs-web-framework 3.3.3 → 3.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  {
2
+ "$schema": "https://app.kilo.ai/config.json",
2
3
  "mcp": {
3
4
  "thinking": {
4
5
  "type": "local",
@@ -57,7 +57,8 @@ export default class ContextManager {
57
57
  // Cargar estado persistido si existe
58
58
  let state = initialState;
59
59
  if (options.persist) {
60
- const persisted = this._loadFromStorage(name);
60
+ const storageKey = options.storageKey || `slice_context_${name}`;
61
+ const persisted = this._loadFromStorage(storageKey);
61
62
  if (persisted !== null) {
62
63
  state = persisted;
63
64
  }
@@ -332,15 +333,14 @@ export default class ContextManager {
332
333
  /**
333
334
  * Cargar estado desde localStorage
334
335
  */
335
- _loadFromStorage(name) {
336
+ _loadFromStorage(storageKey) {
336
337
  try {
337
- const key = `slice_context_${name}`;
338
- const data = localStorage.getItem(key);
338
+ const data = localStorage.getItem(storageKey);
339
339
  if (data) {
340
340
  return JSON.parse(data);
341
341
  }
342
342
  } catch (error) {
343
- slice.logger.logWarning('ContextManager', `Error cargando "${name}" de localStorage`, error);
343
+ slice.logger.logWarning('ContextManager', `Error cargando "${storageKey}" de localStorage`, error);
344
344
  }
345
345
  return null;
346
346
  }
@@ -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
 
@@ -731,8 +736,23 @@ export default class Controller {
731
736
  // Recursively assign parent to children
732
737
  component.querySelectorAll('*').forEach((child) => {
733
738
  if (child.tagName.startsWith('SLICE-')) {
739
+ // Only the call that establishes the DIRECT parent link feeds the
740
+ // index — the depth-first recursion sets a node's parent before an
741
+ // outer ancestor's forEach reaches it, so `component` here is the
742
+ // immediate enclosing component.
734
743
  if (!child.parentComponent) {
735
744
  child.parentComponent = component;
745
+ // 🔁 Maintain childrenIndex so destroyComponent(parent) cascades
746
+ // to children built via slice.build (which registers them WITHOUT
747
+ // a parent, leaving the index otherwise empty). Without this, a
748
+ // parent's destroy never finds — and never cleans up — its children.
749
+ if (child.sliceId && component.sliceId) {
750
+ if (!this.childrenIndex.has(component.sliceId)) {
751
+ this.childrenIndex.set(component.sliceId, new Set());
752
+ }
753
+ this.childrenIndex.get(component.sliceId).add(child.sliceId);
754
+ child._depth = (component._depth || 0) + 1;
755
+ }
736
756
  }
737
757
  this.registerComponentsRecursively(child, component);
738
758
  }
@@ -748,6 +768,33 @@ export default class Controller {
748
768
  return this.activeComponents.get(sliceId);
749
769
  }
750
770
 
771
+ /**
772
+ * Get-or-create a single instance keyed by sliceId, deduplicating concurrent
773
+ * builds. Returns the existing instance if already registered, the in-flight
774
+ * promise if a build is underway, or otherwise memoizes a fresh build via
775
+ * `builder` (an injected closure — the controller never builds by itself).
776
+ *
777
+ * The in-flight promise is removed once it settles, so a failed build (which
778
+ * resolves to `null` and never registers in activeComponents) can be retried
779
+ * by a later call and never poisons the registry.
780
+ *
781
+ * @param {string} sliceId
782
+ * @param {() => Promise<any>} builder
783
+ * @returns {any|Promise<any>} instance (sync) or Promise<instance>
784
+ */
785
+ getOrCreate(sliceId, builder) {
786
+ const existing = this.activeComponents.get(sliceId);
787
+ if (existing) return existing;
788
+
789
+ const pending = this._pendingBuilds.get(sliceId);
790
+ if (pending) return pending;
791
+
792
+ const promise = Promise.resolve(builder())
793
+ .finally(() => this._pendingBuilds.delete(sliceId));
794
+ this._pendingBuilds.set(sliceId, promise);
795
+ return promise;
796
+ }
797
+
751
798
  loadTemplateToComponent(component) {
752
799
  const className = component.constructor.name;
753
800
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-web-framework",
3
- "version": "3.3.3",
3
+ "version": "3.3.5",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=20"