slicejs-web-framework 3.4.3 → 3.5.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 (27) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  3. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +468 -468
  4. package/Slice/Components/Structural/Controller/Controller.js +1406 -1198
  5. package/Slice/Components/Structural/Debugger/Debugger.js +1503 -1503
  6. package/Slice/Components/Structural/EventManager/EventManager.js +651 -366
  7. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +368 -30
  8. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -0
  9. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  10. package/Slice/Components/Structural/Router/Router.js +775 -775
  11. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  12. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +106 -106
  13. package/Slice/Slice.js +663 -627
  14. package/Slice/tests/build-singleton.test.js +244 -244
  15. package/Slice/tests/bundle-v2-runtime-contract.test.js +738 -738
  16. package/Slice/tests/context-patch-use.test.js +95 -95
  17. package/Slice/tests/event-emitters.test.js +79 -0
  18. package/Slice/tests/event-registry.test.js +141 -0
  19. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  20. package/Slice/tests/leak-orphans.test.js +131 -0
  21. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  22. package/api/index.js +308 -281
  23. package/api/utils/publicEnvResolver.js +123 -123
  24. package/package.json +44 -44
  25. package/sliceConfig.schema.json +10 -1
  26. package/src/sliceConfig.json +5 -1
  27. package/types/index.d.ts +12 -0
@@ -1,1198 +1,1406 @@
1
- import components from '/Components/components.js';
2
- import { collectInvalidAllowedValueProps, formatAllowedValuesForLog } from './allowedValuesValidation.js';
3
-
4
- export default class Controller {
5
- constructor() {
6
- this.componentCategories = new Map(Object.entries(components));
7
- this.templates = new Map();
8
- this.classes = new Map();
9
- this.requestedStyles = new Set(); // ✅ CRÍTICO: Para tracking de CSS cargados
10
- this.activeComponents = new Map();
11
-
12
- // 🚀 OPTIMIZACIÓN: Índice inverso para búsqueda rápida de hijos
13
- // parentSliceId → Set<childSliceId>
14
- this.childrenIndex = new Map();
15
-
16
- // 📦 Bundle system
17
- this.loadedBundles = new Set();
18
- this.bundleConfig = null;
19
- this.criticalBundleLoaded = false;
20
- this.bundleImportPromises = new Map();
21
- this.bundleLoadPromises = new Map();
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
-
28
- this.idCounter = 0;
29
- }
30
-
31
- /**
32
- * 📦 Initializes bundle system (called automatically when config is loaded)
33
- */
34
- initializeBundles(config = null) {
35
- if (config) {
36
- this.bundleConfig = config;
37
-
38
- // Register critical bundle components if available
39
- if (config.bundles?.critical) {
40
- // Critical bundle will be loaded explicitly
41
- }
42
- this.criticalBundleLoaded = false;
43
- } else {
44
- // No bundles available, will use individual component loading
45
- this.bundleConfig = null;
46
- this.criticalBundleLoaded = false;
47
- }
48
- }
49
-
50
- /**
51
- * Import a bundle URL once per page session.
52
- * Reuses the same Promise for concurrent callers.
53
- * @param {string} bundlePath
54
- * @returns {Promise<any>}
55
- */
56
- importBundleOnce(bundlePath) {
57
- if (!bundlePath) {
58
- return Promise.reject(new Error('Bundle path is required'));
59
- }
60
-
61
- if (this.bundleImportPromises.has(bundlePath)) {
62
- return this.bundleImportPromises.get(bundlePath);
63
- }
64
-
65
- const importPromise = import(bundlePath).catch((error) => {
66
- this.bundleImportPromises.delete(bundlePath);
67
- throw error;
68
- });
69
-
70
- this.bundleImportPromises.set(bundlePath, importPromise);
71
- return importPromise;
72
- }
73
-
74
- buildBundleImportPath(bundleInfo) {
75
- if (!bundleInfo || typeof bundleInfo.file !== 'string' || bundleInfo.file.length === 0) {
76
- throw new Error('Bundle file is required');
77
- }
78
-
79
- const basePath = `/bundles/${bundleInfo.file}`;
80
- const bundleHash = typeof bundleInfo.hash === 'string' ? bundleInfo.hash.trim() : '';
81
- if (!bundleHash) {
82
- return basePath;
83
- }
84
-
85
- return `${basePath}?v=${encodeURIComponent(bundleHash)}`;
86
- }
87
-
88
- /**
89
- * Validate Bundling V2 module contract.
90
- * Requires named exports: SLICE_BUNDLE_META and registerAll.
91
- * @param {any} bundleModule
92
- * @param {string} [bundleName]
93
- * @returns {{metadata: object, registerAll: Function}}
94
- */
95
- async validateBundleModule(bundleModule, bundleName = 'unknown') {
96
- const metadata = bundleModule?.SLICE_BUNDLE_META;
97
- const registerAll = bundleModule?.registerAll;
98
-
99
- if (!metadata || typeof metadata !== 'object' || typeof registerAll !== 'function') {
100
- throw new Error(
101
- `Bundle "${bundleName}" missing Bundling V2 exports contract: requires SLICE_BUNDLE_META and registerAll`
102
- );
103
- }
104
-
105
- return { metadata, registerAll };
106
- }
107
-
108
- /**
109
- * 📦 Loads a bundle by name or category
110
- */
111
- async loadBundle(bundleName) {
112
- const resolvedBundleName = this.resolveBundleName(bundleName);
113
- if (this.loadedBundles.has(resolvedBundleName)) {
114
- return; // Already loaded
115
- }
116
-
117
- return this.loadBundleWithDependencies(resolvedBundleName, new Set());
118
- }
119
-
120
- async loadBundleWithDependencies(bundleName, loadingStack = new Set()) {
121
- const resolvedBundleName = this.resolveBundleName(bundleName);
122
-
123
- if (this.loadedBundles.has(resolvedBundleName)) {
124
- return;
125
- }
126
-
127
- if (loadingStack.has(resolvedBundleName)) {
128
- throw new Error(`Circular bundle dependency detected: ${Array.from(loadingStack).join(' -> ')} -> ${resolvedBundleName}`);
129
- }
130
-
131
- if (this.bundleLoadPromises.has(resolvedBundleName)) {
132
- return this.bundleLoadPromises.get(resolvedBundleName);
133
- }
134
-
135
- const loadPromise = (async () => {
136
- loadingStack.add(resolvedBundleName);
137
- try {
138
- const bundleInfo = this.getBundleInfo(resolvedBundleName);
139
- if (!bundleInfo) {
140
- slice.logger.logWarning('Controller', `Bundle ${resolvedBundleName} not found in configuration`);
141
- return;
142
- }
143
-
144
- const dependencies = this.getBundleDependencies(bundleInfo);
145
- for (const dependencyName of dependencies) {
146
- await this.loadBundleWithDependencies(dependencyName, loadingStack);
147
- }
148
-
149
- const bundlePath = this.buildBundleImportPath(bundleInfo);
150
- const bundleModule = await this.importBundleOnce(bundlePath);
151
- const { metadata, registerAll } = await this.validateBundleModule(bundleModule, resolvedBundleName);
152
-
153
- const registerResult = await registerAll(this, slice.stylesManager);
154
- this.registerVendorSharedDependencies(bundleModule, metadata, resolvedBundleName, registerResult);
155
-
156
- this.loadedBundles.add(resolvedBundleName);
157
- const loadedBundleKey = metadata.bundleKey;
158
- if (loadedBundleKey && loadedBundleKey !== resolvedBundleName) {
159
- this.loadedBundles.add(loadedBundleKey);
160
- }
161
-
162
- if (metadata.type === 'critical' || resolvedBundleName === 'critical') {
163
- this.criticalBundleLoaded = true;
164
- }
165
- } finally {
166
- loadingStack.delete(resolvedBundleName);
167
- }
168
- })();
169
-
170
- this.bundleLoadPromises.set(resolvedBundleName, loadPromise);
171
- try {
172
- return await loadPromise;
173
- } finally {
174
- this.bundleLoadPromises.delete(resolvedBundleName);
175
- }
176
- }
177
-
178
- resolveBundleName(bundleName) {
179
- if (typeof bundleName !== 'string' || bundleName.length === 0) {
180
- return bundleName;
181
- }
182
-
183
- if (bundleName.toLowerCase() === 'critical') {
184
- return 'critical';
185
- }
186
-
187
- if (this.isVendorSharedAlias(bundleName) && this.getVendorSharedBundleInfo()) {
188
- return 'vendor-shared';
189
- }
190
-
191
- const routeBundleName = this.findBundleNameByAlias(this.bundleConfig?.bundles?.routes, bundleName);
192
- if (routeBundleName) {
193
- return routeBundleName;
194
- }
195
-
196
- const sharedBundleName = this.findBundleNameByAlias(this.bundleConfig?.bundles?.shared, bundleName);
197
- if (sharedBundleName) {
198
- return sharedBundleName;
199
- }
200
-
201
- return bundleName;
202
- }
203
-
204
- findBundleNameByAlias(bundleRegistry, bundleName) {
205
- if (!bundleRegistry || typeof bundleRegistry !== 'object') {
206
- return null;
207
- }
208
-
209
- if (bundleRegistry[bundleName]) {
210
- return bundleName;
211
- }
212
-
213
- const normalizedName = bundleName?.toLowerCase();
214
- if (!normalizedName) {
215
- return null;
216
- }
217
-
218
- return Object.keys(bundleRegistry).find((key) => key.toLowerCase() === normalizedName) || null;
219
- }
220
-
221
- getBundleDependencies(bundleInfo) {
222
- if (!bundleInfo || !Array.isArray(bundleInfo.dependencies)) {
223
- return [];
224
- }
225
-
226
- return bundleInfo.dependencies.filter((dependency) => typeof dependency === 'string' && dependency.length > 0);
227
- }
228
-
229
- findBundleEntryByName(bundleRegistry, bundleName) {
230
- if (!bundleRegistry || typeof bundleRegistry !== 'object') {
231
- return null;
232
- }
233
-
234
- if (bundleRegistry[bundleName]) {
235
- return bundleRegistry[bundleName];
236
- }
237
-
238
- const normalizedName = bundleName?.toLowerCase();
239
- if (!normalizedName) {
240
- return null;
241
- }
242
-
243
- const matchedKey = Object.keys(bundleRegistry).find((key) => key.toLowerCase() === normalizedName);
244
- return matchedKey ? bundleRegistry[matchedKey] : null;
245
- }
246
-
247
- getBundleInfo(bundleName) {
248
- if (bundleName === 'critical') {
249
- return this.bundleConfig?.bundles?.critical || null;
250
- }
251
-
252
- if (this.isVendorSharedAlias(bundleName)) {
253
- return this.getVendorSharedBundleInfo();
254
- }
255
-
256
- return (
257
- this.findBundleEntryByName(this.bundleConfig?.bundles?.routes, bundleName)
258
- || this.findBundleEntryByName(this.bundleConfig?.bundles?.shared, bundleName)
259
- );
260
- }
261
-
262
- getVendorSharedBundleInfo() {
263
- if (this.bundleConfig?.bundles?.vendorShared && typeof this.bundleConfig.bundles.vendorShared === 'object') {
264
- return this.bundleConfig.bundles.vendorShared;
265
- }
266
-
267
- return this.findBundleEntryByName(this.bundleConfig?.bundles?.shared, 'vendor-shared');
268
- }
269
-
270
- isVendorSharedAlias(bundleName) {
271
- if (typeof bundleName !== 'string') {
272
- return false;
273
- }
274
-
275
- const normalized = bundleName.toLowerCase();
276
- return normalized === 'vendor-shared' || normalized === 'vendorshared';
277
- }
278
-
279
- registerVendorSharedDependencies(bundleModule, metadata, bundleName, registerResult) {
280
- const isVendorShared = this.isVendorSharedBundleName(metadata?.bundleKey)
281
- || this.isVendorSharedBundleName(bundleName)
282
- || metadata?.registerVendorSharedDependencies === true;
283
-
284
- if (!isVendorShared) {
285
- return;
286
- }
287
-
288
- let sharedDeps = bundleModule?.SLICE_SHARED_DEPS;
289
- if (!sharedDeps && registerResult && typeof registerResult === 'object') {
290
- sharedDeps = registerResult.SLICE_SHARED_DEPS
291
- || registerResult.SLICE_BUNDLE_DEPENDENCIES
292
- || registerResult;
293
- }
294
-
295
- if (!sharedDeps || typeof sharedDeps !== 'object') {
296
- return;
297
- }
298
-
299
- if (!window.__SLICE_SHARED_DEPS__ || typeof window.__SLICE_SHARED_DEPS__ !== 'object') {
300
- window.__SLICE_SHARED_DEPS__ = {};
301
- }
302
-
303
- Object.assign(window.__SLICE_SHARED_DEPS__, sharedDeps);
304
- }
305
-
306
- isVendorSharedBundleName(bundleName) {
307
- return typeof bundleName === 'string' && bundleName.toLowerCase() === 'vendor-shared';
308
- }
309
-
310
- /**
311
- * 📦 Registers a bundle's components (called automatically by bundle files)
312
- */
313
- registerBundleLegacy(bundle) {
314
- const { components, metadata } = bundle;
315
-
316
- slice.logger.logInfo('Controller', `📦 Registering bundle: ${metadata.type} (${metadata.componentCount} components)`);
317
-
318
- // Phase 1: Register templates and CSS for all components first
319
- for (const [componentName, componentData] of Object.entries(components)) {
320
- try {
321
- // Register HTML template
322
- if (componentData.html !== undefined && !this.templates.has(componentName)) {
323
- const template = document.createElement('template');
324
- template.innerHTML = componentData.html || '';
325
- this.templates.set(componentName, template);
326
- }
327
-
328
- // Register CSS styles
329
- if (componentData.css !== undefined && !this.requestedStyles.has(componentName)) {
330
- // Use the existing stylesManager to register component styles
331
- if (window.slice && window.slice.stylesManager) {
332
- window.slice.stylesManager.registerComponentStyles(componentName, componentData.css || '');
333
- this.requestedStyles.add(componentName);
334
- }
335
- }
336
- } catch (error) {
337
- slice.logger.logError('Controller', `❌ Failed to register assets for ${componentName}`, error);
338
- }
339
- }
340
-
341
- // Phase 2: Evaluate all external file dependencies
342
- const processedDeps = new Set();
343
- for (const [componentName, componentData] of Object.entries(components)) {
344
- if (componentData.dependencies) {
345
- for (const [depName, depContent] of Object.entries(componentData.dependencies)) {
346
- if (!processedDeps.has(depName)) {
347
- try {
348
- // Convert ES6 exports to global assignments
349
- let processedContent = depContent
350
- .replace(/export\s+const\s+(\w+)\s*=/g, 'window.$1 =')
351
- .replace(/export\s+let\s+(\w+)\s*=/g, 'window.$1 =')
352
- .replace(/export\s+var\s+(\w+)\s*=/g, 'window.$1 =')
353
- .replace(/export\s+function\s+(\w+)/g, 'window.$1 = function')
354
- .replace(/export\s+default\s+/g, 'window.defaultExport =')
355
- .replace(/export\s*{\s*([^}]+)\s*}/g, (match, exports) => {
356
- return exports
357
- .split(',')
358
- .map((exp) => {
359
- const cleanExp = exp.trim();
360
- const varName = cleanExp.split(' as ')[0].trim();
361
- return `window.${varName} = ${varName};`;
362
- })
363
- .join('\n');
364
- })
365
- // Remove any remaining export keywords
366
- .replace(/^\s*export\s+/gm, '');
367
-
368
- // Evaluate the dependency
369
- try {
370
- new Function('slice', 'customElements', 'window', 'document', processedContent)(
371
- window.slice,
372
- window.customElements,
373
- window,
374
- window.document
375
- );
376
- } catch (evalError) {
377
- slice.logger.error('Controller', `Failed to evaluate processed dependency ${depName}`, evalError);
378
- slice.logger.warn('Controller', `Processed content preview: ${processedContent.substring(0, 200)}`);
379
- try {
380
- new Function('slice', 'customElements', 'window', 'document', depContent)(
381
- window.slice,
382
- window.customElements,
383
- window,
384
- window.document
385
- );
386
- slice.logger.info('Controller', `Fallback evaluation succeeded for ${depName}`);
387
- } catch (fallbackError) {
388
- slice.logger.error('Controller', `Fallback evaluation also failed for ${depName}`, fallbackError);
389
- }
390
- }
391
-
392
- processedDeps.add(depName);
393
- slice.logger.info('Controller', `Dependency loaded: ${depName}`);
394
- } catch (depError) {
395
- slice.logger.error('Controller', `Failed to load dependency ${depName} for ${componentName}`, depError);
396
- }
397
- }
398
- }
399
- }
400
- }
401
-
402
- // Phase 3: Evaluate all component classes (now that dependencies are available)
403
- for (const [componentName, componentData] of Object.entries(components)) {
404
- // For JavaScript classes, we need to evaluate the code
405
- if (componentData.js && !this.classes.has(componentName)) {
406
- try {
407
- // Create evaluation context with dependencies
408
- let evalCode = componentData.js;
409
-
410
- // Prepend dependencies to make them available
411
- if (componentData.dependencies) {
412
- const depCode = Object.entries(componentData.dependencies)
413
- .map(([depName, depContent]) => {
414
- // Convert ES6 exports to global assignments
415
- return depContent
416
- .replace(/export\s+const\s+(\w+)\s*=/g, 'window.$1 =')
417
- .replace(/export\s+let\s+(\w+)\s*=/g, 'window.$1 =')
418
- .replace(/export\s+function\s+(\w+)/g, 'window.$1 = function')
419
- .replace(/export\s+default\s+/g, 'window.defaultExport =')
420
- .replace(/export\s*{\s*([^}]+)\s*}/g, (match, exports) => {
421
- return exports
422
- .split(',')
423
- .map((exp) => {
424
- const cleanExp = exp.trim();
425
- return `window.${cleanExp} = ${cleanExp};`;
426
- })
427
- .join('\n');
428
- });
429
- })
430
- .join('\n\n');
431
-
432
- evalCode = depCode + '\n\n' + evalCode;
433
- }
434
-
435
- // Evaluate the complete code
436
- const componentClass = new Function(
437
- 'slice',
438
- 'customElements',
439
- 'window',
440
- 'document',
441
- `
442
- "use strict";
443
- ${evalCode}
444
- return ${componentName};
445
- `
446
- )(window.slice, window.customElements, window, window.document);
447
-
448
- if (componentClass) {
449
- this.classes.set(componentName, componentClass);
450
- slice.logger.logInfo('Controller', `📝 Class registered for: ${componentName}`);
451
- }
452
- } catch (error) {
453
- slice.logger.error('Controller', `Failed to evaluate class for ${componentName}`, error);
454
- slice.logger.warn('Controller', `Code that failed: ${componentData.js.substring(0, 200) + '...'}`);
455
- }
456
- }
457
- }
458
- }
459
-
460
- /**
461
- * 📦 New bundle registration method (simplified and robust)
462
- */
463
- registerBundle(bundle) {
464
- const validation = this.validateBundle(bundle);
465
- if (!validation.isValid) {
466
- slice.logger.error('Controller', `Bundle validation failed: ${validation.error}`);
467
- return Promise.resolve(false);
468
- }
469
-
470
- // Set tracking flags synchronously before any async work, so callers that
471
- // await import() see the flags set immediately when the Promise resolves.
472
- const { components, metadata } = bundle;
473
- const bundleKey = metadata?.bundleKey;
474
- if (bundleKey) {
475
- this.loadedBundles.add(bundleKey);
476
- if (metadata?.type === 'critical') {
477
- this.criticalBundleLoaded = true;
478
- }
479
- }
480
-
481
- slice.logger.logInfo('Controller', `📦 Registering bundle: ${metadata.type} (${metadata.componentCount} components)`);
482
-
483
- const entries = Object.entries(components);
484
- const chunkSize = 50;
485
- let index = 0;
486
-
487
- return new Promise((resolve) => {
488
- const processChunk = () => {
489
- try {
490
- const sliceEntries = entries.slice(index, index + chunkSize);
491
-
492
- for (const [componentName, componentData] of sliceEntries) {
493
- try {
494
- if (componentData.html !== undefined && !this.templates.has(componentName)) {
495
- const template = document.createElement('template');
496
- template.innerHTML = componentData.html || '';
497
- this.templates.set(componentName, template);
498
- }
499
-
500
- if (componentData.css !== undefined && !this.requestedStyles.has(componentName)) {
501
- if (window.slice && window.slice.stylesManager) {
502
- window.slice.stylesManager.registerComponentStyles(componentName, componentData.css || '');
503
- this.requestedStyles.add(componentName);
504
- }
505
- }
506
-
507
- if (componentData.class && !this.classes.has(componentName)) {
508
- const registeredName = componentData.isFramework
509
- ? `Framework/Structural/${componentName}`
510
- : componentName;
511
- this.classes.set(registeredName, componentData.class);
512
- if (componentName === 'Loading') {
513
- slice.logger.logInfo('Controller', `Bundle class registered: Loading (registeredName=${registeredName})`);
514
- }
515
- if (componentName === 'InputSearchDocs' || componentName === 'MainMenu') {
516
- slice.logger.logInfo('Controller', `Bundle class registered: ${componentName}`);
517
- }
518
- }
519
- } catch (error) {
520
- slice.logger.error('Controller', `Failed to register component ${componentName}`, error);
521
- }
522
- }
523
-
524
- index += chunkSize;
525
- if (index < entries.length) {
526
- if (typeof requestIdleCallback === 'function') {
527
- requestIdleCallback(processChunk);
528
- } else {
529
- setTimeout(processChunk, 0);
530
- }
531
- return;
532
- }
533
-
534
- slice.logger.info('Controller', `Bundle registration completed: ${metadata.componentCount} components processed`);
535
- resolve(true);
536
- } catch (error) {
537
- slice.logger.error('Controller', 'Fatal error in registerBundle chunk processing', error);
538
- resolve(false);
539
- }
540
- };
541
-
542
- processChunk();
543
- });
544
- }
545
-
546
- /**
547
- * Validates bundle structure before registering.
548
- * @param {object} bundle
549
- * @returns {{isValid: boolean, error?: string}}
550
- */
551
- validateBundle(bundle) {
552
- if (!bundle || typeof bundle !== 'object') {
553
- return { isValid: false, error: 'Bundle payload is invalid' };
554
- }
555
-
556
- if (!bundle.metadata || typeof bundle.metadata !== 'object') {
557
- return { isValid: false, error: 'Bundle metadata missing' };
558
- }
559
-
560
- if (!bundle.components || typeof bundle.components !== 'object') {
561
- return { isValid: false, error: 'Bundle components missing' };
562
- }
563
-
564
- if (typeof bundle.metadata.componentCount !== 'number') {
565
- return { isValid: false, error: 'Bundle metadata missing componentCount' };
566
- }
567
-
568
- if (bundle.metadata.componentCount !== Object.keys(bundle.components).length) {
569
- return { isValid: false, error: 'Bundle component count mismatch' };
570
- }
571
-
572
- const maxComponents = 5000;
573
- if (bundle.metadata.componentCount > maxComponents) {
574
- return { isValid: false, error: 'Bundle component count exceeds limit' };
575
- }
576
-
577
- return { isValid: true };
578
- }
579
-
580
- /**
581
- * 📦 Determines which bundle to load for a component
582
- */
583
- getBundleForComponent(componentName) {
584
- if (!this.bundleConfig?.bundles) return null;
585
-
586
- // Check if component is in critical bundle
587
- if (this.bundleConfig.bundles.critical?.components?.includes(componentName)) {
588
- return 'critical';
589
- }
590
-
591
- // Find component in route bundles
592
- if (this.bundleConfig.bundles.routes) {
593
- for (const [bundleName, bundleInfo] of Object.entries(this.bundleConfig.bundles.routes)) {
594
- if (bundleInfo.components?.includes(componentName)) {
595
- return bundleName;
596
- }
597
- }
598
- }
599
-
600
- return null;
601
- }
602
-
603
- /**
604
- * 📦 Checks if a component is available from loaded bundles
605
- */
606
- isComponentFromBundle(componentName) {
607
- if (!this.bundleConfig?.bundles) return false;
608
-
609
- // Check critical bundle
610
- if (this.bundleConfig.bundles.critical?.components?.includes(componentName)) {
611
- return this.criticalBundleLoaded;
612
- }
613
-
614
- // Check route bundles
615
- if (this.bundleConfig.bundles.routes) {
616
- for (const [bundleName, bundleInfo] of Object.entries(this.bundleConfig.bundles.routes)) {
617
- if (bundleInfo.components?.includes(componentName)) {
618
- return this.loadedBundles.has(bundleName);
619
- }
620
- }
621
- }
622
-
623
- return false;
624
- }
625
-
626
- /**
627
- * 📦 Gets component data from loaded bundles
628
- */
629
- getComponentFromBundle(componentName) {
630
- if (!this.bundleConfig?.bundles) return null;
631
-
632
- // Find component in any loaded bundle
633
- const allBundles = [
634
- { name: 'critical', data: this.bundleConfig.bundles.critical },
635
- ...Object.entries(this.bundleConfig.bundles.routes || {}).map(([name, data]) => ({ name, data })),
636
- ];
637
-
638
- for (const { name: bundleName, data: bundleData } of allBundles) {
639
- if (bundleData?.components?.includes(componentName) && this.loadedBundles.has(bundleName)) {
640
- // Find the bundle file and extract component data
641
- // This is a simplified version - in practice you'd need to access the loaded bundle data
642
- return { bundleName, componentName };
643
- }
644
- }
645
-
646
- return null;
647
- }
648
-
649
- logActiveComponents() {
650
- this.activeComponents.forEach((component) => {
651
- let parent = component.parentComponent;
652
- let parentName = parent ? parent.constructor.name : null;
653
- });
654
- }
655
-
656
- getTopParentsLinkedToActiveComponents() {
657
- let topParentsLinkedToActiveComponents = new Map();
658
- this.activeComponents.forEach((component) => {
659
- let parent = component.parentComponent;
660
- while (parent && parent.parentComponent) {
661
- parent = parent.parentComponent;
662
- }
663
- if (!topParentsLinkedToActiveComponents.has(parent)) {
664
- topParentsLinkedToActiveComponents.set(parent, []);
665
- }
666
- topParentsLinkedToActiveComponents.get(parent).push(component);
667
- });
668
- return topParentsLinkedToActiveComponents;
669
- }
670
-
671
- verifyComponentIds(component) {
672
- const htmlId = component.id;
673
-
674
- if (htmlId && htmlId.trim() !== '') {
675
- if (this.activeComponents.has(htmlId)) {
676
- slice.logger.logError(
677
- 'Controller',
678
- `A component with the same html id attribute is already registered: ${htmlId}`
679
- );
680
- return false;
681
- }
682
- }
683
-
684
- let sliceId = component.sliceId;
685
-
686
- if (sliceId && sliceId.trim() !== '') {
687
- if (this.activeComponents.has(sliceId)) {
688
- slice.logger.logError(
689
- 'Controller',
690
- `A component with the same slice id attribute is already registered: ${sliceId}`
691
- );
692
- return false;
693
- }
694
- } else {
695
- sliceId = `${component.constructor.name[0].toLowerCase()}${component.constructor.name.slice(1)}-${this.idCounter}`;
696
- component.sliceId = sliceId;
697
- this.idCounter++;
698
- }
699
-
700
- component.sliceId = sliceId;
701
- return true;
702
- }
703
-
704
- /**
705
- * Registra un componente y actualiza el índice de relaciones padre-hijo
706
- * 🚀 OPTIMIZADO: Ahora mantiene childrenIndex y precalcula profundidad
707
- */
708
- registerComponent(component, parent = null) {
709
- component.parentComponent = parent;
710
-
711
- // 🚀 OPTIMIZACIÓN: Precalcular y guardar profundidad
712
- component._depth = parent ? (parent._depth || 0) + 1 : 0;
713
-
714
- // Registrar en activeComponents
715
- this.activeComponents.set(component.sliceId, component);
716
-
717
- // Exponer sliceId como atributo HTML para búsqueda por DOM (destroyByContainer, etc.)
718
- if (typeof component.setAttribute === 'function') {
719
- component.setAttribute('slice-id', component.sliceId);
720
- }
721
-
722
- // 🚀 OPTIMIZACIÓN: Actualizar índice inverso de hijos
723
- if (parent) {
724
- if (!this.childrenIndex.has(parent.sliceId)) {
725
- this.childrenIndex.set(parent.sliceId, new Set());
726
- }
727
- this.childrenIndex.get(parent.sliceId).add(component.sliceId);
728
- }
729
-
730
- return true;
731
- }
732
-
733
- registerComponentsRecursively(component, parent = null) {
734
- // Assign parent if not already set
735
- if (!component.parentComponent) {
736
- component.parentComponent = parent;
737
- }
738
-
739
- // Recursively assign parent to children
740
- component.querySelectorAll('*').forEach((child) => {
741
- if (child.tagName.startsWith('SLICE-')) {
742
- // Only the call that establishes the DIRECT parent link feeds the
743
- // index — the depth-first recursion sets a node's parent before an
744
- // outer ancestor's forEach reaches it, so `component` here is the
745
- // immediate enclosing component.
746
- if (!child.parentComponent) {
747
- child.parentComponent = component;
748
- // 🔁 Maintain childrenIndex so destroyComponent(parent) cascades
749
- // to children built via slice.build (which registers them WITHOUT
750
- // a parent, leaving the index otherwise empty). Without this, a
751
- // parent's destroy never finds — and never cleans up — its children.
752
- if (child.sliceId && component.sliceId) {
753
- if (!this.childrenIndex.has(component.sliceId)) {
754
- this.childrenIndex.set(component.sliceId, new Set());
755
- }
756
- this.childrenIndex.get(component.sliceId).add(child.sliceId);
757
- child._depth = (component._depth || 0) + 1;
758
- }
759
- }
760
- this.registerComponentsRecursively(child, component);
761
- }
762
- });
763
- }
764
-
765
- /**
766
- * Get a registered component by sliceId.
767
- * @param {string} sliceId
768
- * @returns {HTMLElement|undefined}
769
- */
770
- getComponent(sliceId) {
771
- return this.activeComponents.get(sliceId);
772
- }
773
-
774
- /**
775
- * Get-or-create a single instance keyed by sliceId, deduplicating concurrent
776
- * builds. Returns the existing instance if already registered, the in-flight
777
- * promise if a build is underway, or otherwise memoizes a fresh build via
778
- * `builder` (an injected closure — the controller never builds by itself).
779
- *
780
- * The in-flight promise is removed once it settles, so a failed build (which
781
- * resolves to `null` and never registers in activeComponents) can be retried
782
- * by a later call and never poisons the registry.
783
- *
784
- * @param {string} sliceId
785
- * @param {() => Promise<any>} builder
786
- * @returns {any|Promise<any>} instance (sync) or Promise<instance>
787
- */
788
- getOrCreate(sliceId, builder) {
789
- const existing = this.activeComponents.get(sliceId);
790
- if (existing) return existing;
791
-
792
- const pending = this._pendingBuilds.get(sliceId);
793
- if (pending) return pending;
794
-
795
- const promise = Promise.resolve(builder())
796
- .finally(() => this._pendingBuilds.delete(sliceId));
797
- this._pendingBuilds.set(sliceId, promise);
798
- return promise;
799
- }
800
-
801
- loadTemplateToComponent(component) {
802
- const className = component.constructor.name;
803
- const template = this.templates.get(className);
804
-
805
- if (!template) {
806
- slice.logger.error('Controller', `Template not found for component: ${className}`);
807
- return;
808
- }
809
-
810
- component.innerHTML = template.innerHTML;
811
- return component;
812
- }
813
-
814
- getComponentCategory(componentSliceId) {
815
- return this.componentCategories.get(componentSliceId);
816
- }
817
-
818
- /**
819
- * Fetch component resources (html, css, styles, theme).
820
- * @param {string} componentName
821
- * @param {'html'|'css'|'theme'|'styles'} resourceType
822
- * @param {string} [componentCategory]
823
- * @param {string} [customPath]
824
- * @returns {Promise<string>}
825
- */
826
- async fetchText(componentName, resourceType, componentCategory, customPath) {
827
- try {
828
- const baseUrl = window.location.origin;
829
- let path;
830
-
831
- if (!componentCategory) {
832
- componentCategory = this.componentCategories.get(componentName);
833
- }
834
-
835
- let isVisual = resourceType === 'html' || resourceType === 'css';
836
-
837
- if (isVisual) {
838
- if (slice.paths.components[componentCategory]) {
839
- path = `${baseUrl}${slice.paths.components[componentCategory].path}/${componentName}`;
840
- resourceType === 'html' ? (path += `/${componentName}.html`) : (path += `/${componentName}.css`);
841
- } else {
842
- if (componentCategory === 'Structural') {
843
- path = `${baseUrl}/Slice/Components/Structural/${componentName}`;
844
- resourceType === 'html' ? (path += `/${componentName}.html`) : (path += `/${componentName}.css`);
845
- } else {
846
- throw new Error(`Component category '${componentCategory}' not found in paths configuration`);
847
- }
848
- }
849
- }
850
-
851
- if (resourceType === 'theme') {
852
- path = `${baseUrl}${slice.paths.themes}/${componentName}.css`;
853
- }
854
-
855
- if (resourceType === 'styles') {
856
- path = `${baseUrl}${slice.paths.styles}/${componentName}.css`;
857
- }
858
-
859
- if (customPath) {
860
- path = customPath;
861
- }
862
-
863
- slice.logger.logInfo('Controller', `Fetching ${resourceType} from: ${path}`);
864
-
865
- const response = await fetch(path);
866
-
867
- if (!response.ok) {
868
- throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
869
- }
870
-
871
- const content = await response.text();
872
- slice.logger.logInfo('Controller', `Successfully fetched ${resourceType} for ${componentName}`);
873
- return content;
874
- } catch (error) {
875
- slice.logger.logError('Controller', `Error fetching ${resourceType} for component ${componentName}:`, error);
876
- throw error;
877
- }
878
- }
879
-
880
- /**
881
- * Apply props to a component using static defaults and setters.
882
- * @param {HTMLElement} component
883
- * @param {Object} props
884
- * @returns {void}
885
- */
886
- setComponentProps(component, props) {
887
- const ComponentClass = component.constructor;
888
- const componentName = ComponentClass.name;
889
-
890
- // Aplicar defaults si tiene static props
891
- if (ComponentClass.props) {
892
- this.applyDefaultProps(component, ComponentClass.props, props);
893
- }
894
-
895
- // Validar solo en desarrollo
896
- if (ComponentClass.props && !slice.isProduction()) {
897
- this.validatePropsInDevelopment(ComponentClass, props, componentName);
898
- }
899
-
900
- // Aplicar props
901
- for (const prop in props) {
902
- component[`_${prop}`] = null;
903
- component[prop] = props[prop];
904
- }
905
- }
906
-
907
- getComponentPropsForDebugger(component) {
908
- const ComponentClass = component.constructor;
909
-
910
- if (ComponentClass.props) {
911
- return {
912
- availableProps: Object.keys(ComponentClass.props),
913
- propsConfig: ComponentClass.props,
914
- usedProps: this.extractUsedProps(component, ComponentClass.props),
915
- };
916
- } else {
917
- return {
918
- availableProps: this.extractUsedProps(component),
919
- propsConfig: null,
920
- usedProps: this.extractUsedProps(component),
921
- };
922
- }
923
- }
924
-
925
- applyDefaultProps(component, staticProps, providedProps) {
926
- Object.entries(staticProps).forEach(([prop, config]) => {
927
- if (config.default !== undefined && !(prop in (providedProps || {}))) {
928
- component[`_${prop}`] = null;
929
- component[prop] = config.default;
930
- }
931
- });
932
- }
933
-
934
- validatePropsInDevelopment(ComponentClass, providedProps, componentName) {
935
- const staticProps = ComponentClass.props;
936
- const usedProps = Object.keys(providedProps || {});
937
-
938
- const availableProps = Object.keys(staticProps);
939
- const unknownProps = usedProps.filter((prop) => !availableProps.includes(prop));
940
-
941
- if (unknownProps.length > 0) {
942
- slice.logger.logWarning(
943
- 'PropsValidator',
944
- `${componentName}: Unknown props [${unknownProps.join(', ')}]. Available: [${availableProps.join(', ')}]`
945
- );
946
- }
947
-
948
- const requiredProps = Object.entries(staticProps)
949
- .filter(([_, config]) => config.required)
950
- .map(([prop, _]) => prop);
951
-
952
- const missingRequired = requiredProps.filter((prop) => !(prop in (providedProps || {})));
953
- if (missingRequired.length > 0) {
954
- slice.logger.logError(componentName, `Missing required props: [${missingRequired.join(', ')}]`);
955
- }
956
-
957
- const invalidAllowedValueProps = collectInvalidAllowedValueProps(staticProps, providedProps);
958
- invalidAllowedValueProps.forEach(({ propName, value, allowedValues }) => {
959
- const safeValue = (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
960
- slice.logger.error(
961
- componentName,
962
- `Invalid value for prop "${propName}": ${safeValue}. Allowed values: ${formatAllowedValuesForLog(allowedValues)}`
963
- );
964
- });
965
- }
966
-
967
- extractUsedProps(component, staticProps = null) {
968
- const usedProps = {};
969
-
970
- if (staticProps) {
971
- Object.keys(staticProps).forEach((prop) => {
972
- if (component[prop] !== undefined) {
973
- usedProps[prop] = component[prop];
974
- }
975
- });
976
- } else {
977
- Object.getOwnPropertyNames(component).forEach((key) => {
978
- if (key.startsWith('_') && key !== '_isActive') {
979
- const propName = key.substring(1);
980
- usedProps[propName] = component[propName];
981
- }
982
- });
983
- }
984
-
985
- return usedProps;
986
- }
987
-
988
- // ============================================================================
989
- // 🚀 MÉTODOS DE DESTRUCCIÓN OPTIMIZADOS
990
- // ============================================================================
991
-
992
- /**
993
- * Encuentra recursivamente todos los hijos de un componente
994
- * 🚀 OPTIMIZADO: O(m) en lugar de O(n*d) - usa childrenIndex
995
- * @param {string} parentSliceId - sliceId del componente padre
996
- * @param {Set<string>} collected - Set de sliceIds ya recolectados
997
- * @returns {Set<string>} Set de todos los sliceIds de componentes hijos
998
- */
999
- findAllChildComponents(parentSliceId, collected = new Set()) {
1000
- // 🚀 Buscar directamente en el índice: O(1)
1001
- const children = this.childrenIndex.get(parentSliceId);
1002
-
1003
- if (!children) return collected;
1004
-
1005
- // 🚀 Iterar solo los hijos directos: O(k) donde k = número de hijos
1006
- for (const childSliceId of children) {
1007
- collected.add(childSliceId);
1008
- // Recursión solo sobre hijos, no todos los componentes
1009
- this.findAllChildComponents(childSliceId, collected);
1010
- }
1011
-
1012
- return collected;
1013
- }
1014
-
1015
- /**
1016
- * Encuentra recursivamente todos los componentes dentro de un contenedor DOM
1017
- * Útil para destroyByContainer cuando no tenemos el sliceId del padre
1018
- * @param {HTMLElement} container - Elemento contenedor
1019
- * @param {Set<string>} collected - Set de sliceIds ya recolectados
1020
- * @returns {Set<string>} Set de todos los sliceIds encontrados
1021
- */
1022
- findAllNestedComponentsInContainer(container, collected = new Set()) {
1023
- // Buscar todos los elementos con slice-id en el contenedor
1024
- const sliceComponents = container.querySelectorAll('[slice-id]');
1025
-
1026
- sliceComponents.forEach((element) => {
1027
- const sliceId = element.getAttribute('slice-id') || element.sliceId;
1028
- if (sliceId && this.activeComponents.has(sliceId)) {
1029
- collected.add(sliceId);
1030
- // 🚀 Usar índice para buscar hijos recursivamente
1031
- this.findAllChildComponents(sliceId, collected);
1032
- }
1033
- });
1034
-
1035
- return collected;
1036
- }
1037
-
1038
- /**
1039
- * Destruye uno o múltiples componentes DE FORMA RECURSIVA
1040
- * 🚀 OPTIMIZADO: O(m log m) en lugar de O(n*d + m log m)
1041
- * @param {HTMLElement|Array<HTMLElement>|string|Array<string>} components
1042
- * @returns {number} Cantidad de componentes destruidos (incluyendo hijos)
1043
- */
1044
- destroyComponent(components) {
1045
- const toDestroy = Array.isArray(components) ? components : [components];
1046
- const allSliceIdsToDestroy = new Set();
1047
-
1048
- // PASO 1: Recolectar todos los componentes padres y sus hijos recursivamente
1049
- for (const item of toDestroy) {
1050
- let sliceId = null;
1051
-
1052
- if (typeof item === 'string') {
1053
- if (!this.activeComponents.has(item)) {
1054
- slice.logger.logWarning('Controller', `Component with sliceId "${item}" not found`);
1055
- continue;
1056
- }
1057
- sliceId = item;
1058
- } else if (item && item.sliceId) {
1059
- sliceId = item.sliceId;
1060
- } else {
1061
- slice.logger.logWarning('Controller', `Invalid component or sliceId provided to destroyComponent`);
1062
- continue;
1063
- }
1064
-
1065
- allSliceIdsToDestroy.add(sliceId);
1066
-
1067
- // 🚀 OPTIMIZADO: Usa childrenIndex en lugar de recorrer todos los componentes
1068
- this.findAllChildComponents(sliceId, allSliceIdsToDestroy);
1069
- }
1070
-
1071
- // PASO 2: Ordenar por profundidad (más profundos primero)
1072
- // 🚀 OPTIMIZADO: Usa _depth precalculada en lugar de calcularla cada vez
1073
- const sortedSliceIds = Array.from(allSliceIdsToDestroy).sort((a, b) => {
1074
- const compA = this.activeComponents.get(a);
1075
- const compB = this.activeComponents.get(b);
1076
-
1077
- if (!compA || !compB) return 0;
1078
-
1079
- // 🚀 O(1) en lugar de O(d) - usa profundidad precalculada
1080
- return (compB._depth || 0) - (compA._depth || 0);
1081
- });
1082
-
1083
- let destroyedCount = 0;
1084
-
1085
- // PASO 3: Destruir en orden correcto (hijos antes que padres)
1086
- for (const sliceId of sortedSliceIds) {
1087
- const component = this.activeComponents.get(sliceId);
1088
-
1089
- if (!component) continue;
1090
-
1091
- // Ejecutar hook beforeDestroy si existe
1092
- if (typeof component.beforeDestroy === 'function') {
1093
- try {
1094
- component.beforeDestroy();
1095
- } catch (error) {
1096
- slice.logger.logError('Controller', `Error in beforeDestroy for ${sliceId}`, error);
1097
- }
1098
- }
1099
-
1100
- // Limpiar suscripciones de eventos del componente
1101
- if (slice.events) {
1102
- slice.events.cleanupComponent(sliceId);
1103
- }
1104
-
1105
- // 🚀 Limpiar del índice de hijos
1106
- this.childrenIndex.delete(sliceId);
1107
-
1108
- // Si tiene padre, remover de la lista de hijos del padre
1109
- if (component.parentComponent) {
1110
- const parentChildren = this.childrenIndex.get(component.parentComponent.sliceId);
1111
- if (parentChildren) {
1112
- parentChildren.delete(sliceId);
1113
- // Si el padre no tiene más hijos, eliminar entrada vacía
1114
- if (parentChildren.size === 0) {
1115
- this.childrenIndex.delete(component.parentComponent.sliceId);
1116
- }
1117
- }
1118
- }
1119
-
1120
- // Eliminar del mapa de componentes activos
1121
- this.activeComponents.delete(sliceId);
1122
-
1123
- // Remover del DOM si está conectado
1124
- if (component.isConnected) {
1125
- component.remove();
1126
- }
1127
-
1128
- destroyedCount++;
1129
- }
1130
-
1131
- if (destroyedCount > 0) {
1132
- slice.logger.logInfo('Controller', `Destroyed ${destroyedCount} component(s) recursively`);
1133
- }
1134
-
1135
- return destroyedCount;
1136
- }
1137
-
1138
- /**
1139
- * Destruye todos los componentes Slice dentro de un contenedor (RECURSIVO)
1140
- * 🚀 OPTIMIZADO: Usa el índice inverso para búsqueda rápida
1141
- * @param {HTMLElement} container - Elemento contenedor
1142
- * @returns {number} Cantidad de componentes destruidos
1143
- */
1144
- destroyByContainer(container) {
1145
- if (!container) {
1146
- slice.logger.logWarning('Controller', 'No container provided to destroyByContainer');
1147
- return 0;
1148
- }
1149
-
1150
- // 🚀 Recolectar componentes usando índice optimizado
1151
- const allSliceIds = this.findAllNestedComponentsInContainer(container);
1152
-
1153
- if (allSliceIds.size === 0) {
1154
- return 0;
1155
- }
1156
-
1157
- // Destruir usando el método principal optimizado
1158
- const count = this.destroyComponent(Array.from(allSliceIds));
1159
-
1160
- if (count > 0) {
1161
- slice.logger.logInfo('Controller', `Destroyed ${count} component(s) from container (including nested)`);
1162
- }
1163
-
1164
- return count;
1165
- }
1166
-
1167
- /**
1168
- * Destruye componentes cuyos sliceId coincidan con un patrón (RECURSIVO)
1169
- * 🚀 OPTIMIZADO: Usa destrucción optimizada
1170
- * @param {string|RegExp} pattern - Patrón a buscar
1171
- * @returns {number} Cantidad de componentes destruidos
1172
- */
1173
- destroyByPattern(pattern) {
1174
- const componentsToDestroy = [];
1175
- const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern);
1176
-
1177
- for (const [sliceId, component] of this.activeComponents) {
1178
- if (regex.test(sliceId)) {
1179
- componentsToDestroy.push(component);
1180
- }
1181
- }
1182
-
1183
- if (componentsToDestroy.length === 0) {
1184
- return 0;
1185
- }
1186
-
1187
- const count = this.destroyComponent(componentsToDestroy);
1188
-
1189
- if (count > 0) {
1190
- slice.logger.logInfo(
1191
- 'Controller',
1192
- `Destroyed ${count} component(s) matching pattern: ${pattern} (including nested)`
1193
- );
1194
- }
1195
-
1196
- return count;
1197
- }
1198
- }
1
+ import components from '/Components/components.js';
2
+ import { collectInvalidAllowedValueProps, formatAllowedValuesForLog } from './allowedValuesValidation.js';
3
+
4
+ export default class Controller {
5
+ constructor() {
6
+ this.componentCategories = new Map(Object.entries(components));
7
+ this.templates = new Map();
8
+ this.classes = new Map();
9
+ this.requestedStyles = new Set(); // ✅ CRÍTICO: Para tracking de CSS cargados
10
+ this.activeComponents = new Map();
11
+
12
+ // 🚀 OPTIMIZACIÓN: Índice inverso para búsqueda rápida de hijos
13
+ // parentSliceId → Set<childSliceId>
14
+ this.childrenIndex = new Map();
15
+
16
+ // 📦 Bundle system
17
+ this.loadedBundles = new Set();
18
+ this.bundleConfig = null;
19
+ this.criticalBundleLoaded = false;
20
+ this.bundleImportPromises = new Map();
21
+ this.bundleLoadPromises = new Map();
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
+
28
+ this.idCounter = 0;
29
+
30
+ // 🔍 Leak detection (dev-only). App-provided predicates that mark a
31
+ // detached-but-registered component as intentional (not a leak).
32
+ this._leakExclusions = [];
33
+ // Ring buffer of activeComponents.size samples for the growth heuristic.
34
+ this._growthSamples = [];
35
+ }
36
+
37
+ /**
38
+ * 📦 Initializes bundle system (called automatically when config is loaded)
39
+ */
40
+ initializeBundles(config = null) {
41
+ if (config) {
42
+ this.bundleConfig = config;
43
+
44
+ // Register critical bundle components if available
45
+ if (config.bundles?.critical) {
46
+ // Critical bundle will be loaded explicitly
47
+ }
48
+ this.criticalBundleLoaded = false;
49
+ } else {
50
+ // No bundles available, will use individual component loading
51
+ this.bundleConfig = null;
52
+ this.criticalBundleLoaded = false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Import a bundle URL once per page session.
58
+ * Reuses the same Promise for concurrent callers.
59
+ * @param {string} bundlePath
60
+ * @returns {Promise<any>}
61
+ */
62
+ importBundleOnce(bundlePath) {
63
+ if (!bundlePath) {
64
+ return Promise.reject(new Error('Bundle path is required'));
65
+ }
66
+
67
+ if (this.bundleImportPromises.has(bundlePath)) {
68
+ return this.bundleImportPromises.get(bundlePath);
69
+ }
70
+
71
+ const importPromise = import(bundlePath).catch((error) => {
72
+ this.bundleImportPromises.delete(bundlePath);
73
+ throw error;
74
+ });
75
+
76
+ this.bundleImportPromises.set(bundlePath, importPromise);
77
+ return importPromise;
78
+ }
79
+
80
+ buildBundleImportPath(bundleInfo) {
81
+ if (!bundleInfo || typeof bundleInfo.file !== 'string' || bundleInfo.file.length === 0) {
82
+ throw new Error('Bundle file is required');
83
+ }
84
+
85
+ const basePath = `/bundles/${bundleInfo.file}`;
86
+ const bundleHash = typeof bundleInfo.hash === 'string' ? bundleInfo.hash.trim() : '';
87
+ if (!bundleHash) {
88
+ return basePath;
89
+ }
90
+
91
+ return `${basePath}?v=${encodeURIComponent(bundleHash)}`;
92
+ }
93
+
94
+ /**
95
+ * Validate Bundling V2 module contract.
96
+ * Requires named exports: SLICE_BUNDLE_META and registerAll.
97
+ * @param {any} bundleModule
98
+ * @param {string} [bundleName]
99
+ * @returns {{metadata: object, registerAll: Function}}
100
+ */
101
+ async validateBundleModule(bundleModule, bundleName = 'unknown') {
102
+ const metadata = bundleModule?.SLICE_BUNDLE_META;
103
+ const registerAll = bundleModule?.registerAll;
104
+
105
+ if (!metadata || typeof metadata !== 'object' || typeof registerAll !== 'function') {
106
+ throw new Error(
107
+ `Bundle "${bundleName}" missing Bundling V2 exports contract: requires SLICE_BUNDLE_META and registerAll`
108
+ );
109
+ }
110
+
111
+ return { metadata, registerAll };
112
+ }
113
+
114
+ /**
115
+ * 📦 Loads a bundle by name or category
116
+ */
117
+ async loadBundle(bundleName) {
118
+ const resolvedBundleName = this.resolveBundleName(bundleName);
119
+ if (this.loadedBundles.has(resolvedBundleName)) {
120
+ return; // Already loaded
121
+ }
122
+
123
+ return this.loadBundleWithDependencies(resolvedBundleName, new Set());
124
+ }
125
+
126
+ async loadBundleWithDependencies(bundleName, loadingStack = new Set()) {
127
+ const resolvedBundleName = this.resolveBundleName(bundleName);
128
+
129
+ if (this.loadedBundles.has(resolvedBundleName)) {
130
+ return;
131
+ }
132
+
133
+ if (loadingStack.has(resolvedBundleName)) {
134
+ throw new Error(`Circular bundle dependency detected: ${Array.from(loadingStack).join(' -> ')} -> ${resolvedBundleName}`);
135
+ }
136
+
137
+ if (this.bundleLoadPromises.has(resolvedBundleName)) {
138
+ return this.bundleLoadPromises.get(resolvedBundleName);
139
+ }
140
+
141
+ const loadPromise = (async () => {
142
+ loadingStack.add(resolvedBundleName);
143
+ try {
144
+ const bundleInfo = this.getBundleInfo(resolvedBundleName);
145
+ if (!bundleInfo) {
146
+ slice.logger.logWarning('Controller', `Bundle ${resolvedBundleName} not found in configuration`);
147
+ return;
148
+ }
149
+
150
+ const dependencies = this.getBundleDependencies(bundleInfo);
151
+ for (const dependencyName of dependencies) {
152
+ await this.loadBundleWithDependencies(dependencyName, loadingStack);
153
+ }
154
+
155
+ const bundlePath = this.buildBundleImportPath(bundleInfo);
156
+ const bundleModule = await this.importBundleOnce(bundlePath);
157
+ const { metadata, registerAll } = await this.validateBundleModule(bundleModule, resolvedBundleName);
158
+
159
+ const registerResult = await registerAll(this, slice.stylesManager);
160
+ this.registerVendorSharedDependencies(bundleModule, metadata, resolvedBundleName, registerResult);
161
+
162
+ this.loadedBundles.add(resolvedBundleName);
163
+ const loadedBundleKey = metadata.bundleKey;
164
+ if (loadedBundleKey && loadedBundleKey !== resolvedBundleName) {
165
+ this.loadedBundles.add(loadedBundleKey);
166
+ }
167
+
168
+ if (metadata.type === 'critical' || resolvedBundleName === 'critical') {
169
+ this.criticalBundleLoaded = true;
170
+ }
171
+ } finally {
172
+ loadingStack.delete(resolvedBundleName);
173
+ }
174
+ })();
175
+
176
+ this.bundleLoadPromises.set(resolvedBundleName, loadPromise);
177
+ try {
178
+ return await loadPromise;
179
+ } finally {
180
+ this.bundleLoadPromises.delete(resolvedBundleName);
181
+ }
182
+ }
183
+
184
+ resolveBundleName(bundleName) {
185
+ if (typeof bundleName !== 'string' || bundleName.length === 0) {
186
+ return bundleName;
187
+ }
188
+
189
+ if (bundleName.toLowerCase() === 'critical') {
190
+ return 'critical';
191
+ }
192
+
193
+ if (this.isVendorSharedAlias(bundleName) && this.getVendorSharedBundleInfo()) {
194
+ return 'vendor-shared';
195
+ }
196
+
197
+ const routeBundleName = this.findBundleNameByAlias(this.bundleConfig?.bundles?.routes, bundleName);
198
+ if (routeBundleName) {
199
+ return routeBundleName;
200
+ }
201
+
202
+ const sharedBundleName = this.findBundleNameByAlias(this.bundleConfig?.bundles?.shared, bundleName);
203
+ if (sharedBundleName) {
204
+ return sharedBundleName;
205
+ }
206
+
207
+ return bundleName;
208
+ }
209
+
210
+ findBundleNameByAlias(bundleRegistry, bundleName) {
211
+ if (!bundleRegistry || typeof bundleRegistry !== 'object') {
212
+ return null;
213
+ }
214
+
215
+ if (bundleRegistry[bundleName]) {
216
+ return bundleName;
217
+ }
218
+
219
+ const normalizedName = bundleName?.toLowerCase();
220
+ if (!normalizedName) {
221
+ return null;
222
+ }
223
+
224
+ return Object.keys(bundleRegistry).find((key) => key.toLowerCase() === normalizedName) || null;
225
+ }
226
+
227
+ getBundleDependencies(bundleInfo) {
228
+ if (!bundleInfo || !Array.isArray(bundleInfo.dependencies)) {
229
+ return [];
230
+ }
231
+
232
+ return bundleInfo.dependencies.filter((dependency) => typeof dependency === 'string' && dependency.length > 0);
233
+ }
234
+
235
+ findBundleEntryByName(bundleRegistry, bundleName) {
236
+ if (!bundleRegistry || typeof bundleRegistry !== 'object') {
237
+ return null;
238
+ }
239
+
240
+ if (bundleRegistry[bundleName]) {
241
+ return bundleRegistry[bundleName];
242
+ }
243
+
244
+ const normalizedName = bundleName?.toLowerCase();
245
+ if (!normalizedName) {
246
+ return null;
247
+ }
248
+
249
+ const matchedKey = Object.keys(bundleRegistry).find((key) => key.toLowerCase() === normalizedName);
250
+ return matchedKey ? bundleRegistry[matchedKey] : null;
251
+ }
252
+
253
+ getBundleInfo(bundleName) {
254
+ if (bundleName === 'critical') {
255
+ return this.bundleConfig?.bundles?.critical || null;
256
+ }
257
+
258
+ if (this.isVendorSharedAlias(bundleName)) {
259
+ return this.getVendorSharedBundleInfo();
260
+ }
261
+
262
+ return (
263
+ this.findBundleEntryByName(this.bundleConfig?.bundles?.routes, bundleName)
264
+ || this.findBundleEntryByName(this.bundleConfig?.bundles?.shared, bundleName)
265
+ );
266
+ }
267
+
268
+ getVendorSharedBundleInfo() {
269
+ if (this.bundleConfig?.bundles?.vendorShared && typeof this.bundleConfig.bundles.vendorShared === 'object') {
270
+ return this.bundleConfig.bundles.vendorShared;
271
+ }
272
+
273
+ return this.findBundleEntryByName(this.bundleConfig?.bundles?.shared, 'vendor-shared');
274
+ }
275
+
276
+ isVendorSharedAlias(bundleName) {
277
+ if (typeof bundleName !== 'string') {
278
+ return false;
279
+ }
280
+
281
+ const normalized = bundleName.toLowerCase();
282
+ return normalized === 'vendor-shared' || normalized === 'vendorshared';
283
+ }
284
+
285
+ registerVendorSharedDependencies(bundleModule, metadata, bundleName, registerResult) {
286
+ const isVendorShared = this.isVendorSharedBundleName(metadata?.bundleKey)
287
+ || this.isVendorSharedBundleName(bundleName)
288
+ || metadata?.registerVendorSharedDependencies === true;
289
+
290
+ if (!isVendorShared) {
291
+ return;
292
+ }
293
+
294
+ let sharedDeps = bundleModule?.SLICE_SHARED_DEPS;
295
+ if (!sharedDeps && registerResult && typeof registerResult === 'object') {
296
+ sharedDeps = registerResult.SLICE_SHARED_DEPS
297
+ || registerResult.SLICE_BUNDLE_DEPENDENCIES
298
+ || registerResult;
299
+ }
300
+
301
+ if (!sharedDeps || typeof sharedDeps !== 'object') {
302
+ return;
303
+ }
304
+
305
+ if (!window.__SLICE_SHARED_DEPS__ || typeof window.__SLICE_SHARED_DEPS__ !== 'object') {
306
+ window.__SLICE_SHARED_DEPS__ = {};
307
+ }
308
+
309
+ Object.assign(window.__SLICE_SHARED_DEPS__, sharedDeps);
310
+ }
311
+
312
+ isVendorSharedBundleName(bundleName) {
313
+ return typeof bundleName === 'string' && bundleName.toLowerCase() === 'vendor-shared';
314
+ }
315
+
316
+ /**
317
+ * 📦 Registers a bundle's components (called automatically by bundle files)
318
+ */
319
+ registerBundleLegacy(bundle) {
320
+ const { components, metadata } = bundle;
321
+
322
+ slice.logger.logInfo('Controller', `📦 Registering bundle: ${metadata.type} (${metadata.componentCount} components)`);
323
+
324
+ // Phase 1: Register templates and CSS for all components first
325
+ for (const [componentName, componentData] of Object.entries(components)) {
326
+ try {
327
+ // Register HTML template
328
+ if (componentData.html !== undefined && !this.templates.has(componentName)) {
329
+ const template = document.createElement('template');
330
+ template.innerHTML = componentData.html || '';
331
+ this.templates.set(componentName, template);
332
+ }
333
+
334
+ // Register CSS styles
335
+ if (componentData.css !== undefined && !this.requestedStyles.has(componentName)) {
336
+ // Use the existing stylesManager to register component styles
337
+ if (window.slice && window.slice.stylesManager) {
338
+ window.slice.stylesManager.registerComponentStyles(componentName, componentData.css || '');
339
+ this.requestedStyles.add(componentName);
340
+ }
341
+ }
342
+ } catch (error) {
343
+ slice.logger.logError('Controller', `❌ Failed to register assets for ${componentName}`, error);
344
+ }
345
+ }
346
+
347
+ // Phase 2: Evaluate all external file dependencies
348
+ const processedDeps = new Set();
349
+ for (const [componentName, componentData] of Object.entries(components)) {
350
+ if (componentData.dependencies) {
351
+ for (const [depName, depContent] of Object.entries(componentData.dependencies)) {
352
+ if (!processedDeps.has(depName)) {
353
+ try {
354
+ // Convert ES6 exports to global assignments
355
+ let processedContent = depContent
356
+ .replace(/export\s+const\s+(\w+)\s*=/g, 'window.$1 =')
357
+ .replace(/export\s+let\s+(\w+)\s*=/g, 'window.$1 =')
358
+ .replace(/export\s+var\s+(\w+)\s*=/g, 'window.$1 =')
359
+ .replace(/export\s+function\s+(\w+)/g, 'window.$1 = function')
360
+ .replace(/export\s+default\s+/g, 'window.defaultExport =')
361
+ .replace(/export\s*{\s*([^}]+)\s*}/g, (match, exports) => {
362
+ return exports
363
+ .split(',')
364
+ .map((exp) => {
365
+ const cleanExp = exp.trim();
366
+ const varName = cleanExp.split(' as ')[0].trim();
367
+ return `window.${varName} = ${varName};`;
368
+ })
369
+ .join('\n');
370
+ })
371
+ // Remove any remaining export keywords
372
+ .replace(/^\s*export\s+/gm, '');
373
+
374
+ // Evaluate the dependency
375
+ try {
376
+ new Function('slice', 'customElements', 'window', 'document', processedContent)(
377
+ window.slice,
378
+ window.customElements,
379
+ window,
380
+ window.document
381
+ );
382
+ } catch (evalError) {
383
+ slice.logger.error('Controller', `Failed to evaluate processed dependency ${depName}`, evalError);
384
+ slice.logger.warn('Controller', `Processed content preview: ${processedContent.substring(0, 200)}`);
385
+ try {
386
+ new Function('slice', 'customElements', 'window', 'document', depContent)(
387
+ window.slice,
388
+ window.customElements,
389
+ window,
390
+ window.document
391
+ );
392
+ slice.logger.info('Controller', `Fallback evaluation succeeded for ${depName}`);
393
+ } catch (fallbackError) {
394
+ slice.logger.error('Controller', `Fallback evaluation also failed for ${depName}`, fallbackError);
395
+ }
396
+ }
397
+
398
+ processedDeps.add(depName);
399
+ slice.logger.info('Controller', `Dependency loaded: ${depName}`);
400
+ } catch (depError) {
401
+ slice.logger.error('Controller', `Failed to load dependency ${depName} for ${componentName}`, depError);
402
+ }
403
+ }
404
+ }
405
+ }
406
+ }
407
+
408
+ // Phase 3: Evaluate all component classes (now that dependencies are available)
409
+ for (const [componentName, componentData] of Object.entries(components)) {
410
+ // For JavaScript classes, we need to evaluate the code
411
+ if (componentData.js && !this.classes.has(componentName)) {
412
+ try {
413
+ // Create evaluation context with dependencies
414
+ let evalCode = componentData.js;
415
+
416
+ // Prepend dependencies to make them available
417
+ if (componentData.dependencies) {
418
+ const depCode = Object.entries(componentData.dependencies)
419
+ .map(([depName, depContent]) => {
420
+ // Convert ES6 exports to global assignments
421
+ return depContent
422
+ .replace(/export\s+const\s+(\w+)\s*=/g, 'window.$1 =')
423
+ .replace(/export\s+let\s+(\w+)\s*=/g, 'window.$1 =')
424
+ .replace(/export\s+function\s+(\w+)/g, 'window.$1 = function')
425
+ .replace(/export\s+default\s+/g, 'window.defaultExport =')
426
+ .replace(/export\s*{\s*([^}]+)\s*}/g, (match, exports) => {
427
+ return exports
428
+ .split(',')
429
+ .map((exp) => {
430
+ const cleanExp = exp.trim();
431
+ return `window.${cleanExp} = ${cleanExp};`;
432
+ })
433
+ .join('\n');
434
+ });
435
+ })
436
+ .join('\n\n');
437
+
438
+ evalCode = depCode + '\n\n' + evalCode;
439
+ }
440
+
441
+ // Evaluate the complete code
442
+ const componentClass = new Function(
443
+ 'slice',
444
+ 'customElements',
445
+ 'window',
446
+ 'document',
447
+ `
448
+ "use strict";
449
+ ${evalCode}
450
+ return ${componentName};
451
+ `
452
+ )(window.slice, window.customElements, window, window.document);
453
+
454
+ if (componentClass) {
455
+ this.classes.set(componentName, componentClass);
456
+ slice.logger.logInfo('Controller', `📝 Class registered for: ${componentName}`);
457
+ }
458
+ } catch (error) {
459
+ slice.logger.error('Controller', `Failed to evaluate class for ${componentName}`, error);
460
+ slice.logger.warn('Controller', `Code that failed: ${componentData.js.substring(0, 200) + '...'}`);
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ /**
467
+ * 📦 New bundle registration method (simplified and robust)
468
+ */
469
+ registerBundle(bundle) {
470
+ const validation = this.validateBundle(bundle);
471
+ if (!validation.isValid) {
472
+ slice.logger.error('Controller', `Bundle validation failed: ${validation.error}`);
473
+ return Promise.resolve(false);
474
+ }
475
+
476
+ // Set tracking flags synchronously before any async work, so callers that
477
+ // await import() see the flags set immediately when the Promise resolves.
478
+ const { components, metadata } = bundle;
479
+ const bundleKey = metadata?.bundleKey;
480
+ if (bundleKey) {
481
+ this.loadedBundles.add(bundleKey);
482
+ if (metadata?.type === 'critical') {
483
+ this.criticalBundleLoaded = true;
484
+ }
485
+ }
486
+
487
+ slice.logger.logInfo('Controller', `📦 Registering bundle: ${metadata.type} (${metadata.componentCount} components)`);
488
+
489
+ const entries = Object.entries(components);
490
+ const chunkSize = 50;
491
+ let index = 0;
492
+
493
+ return new Promise((resolve) => {
494
+ const processChunk = () => {
495
+ try {
496
+ const sliceEntries = entries.slice(index, index + chunkSize);
497
+
498
+ for (const [componentName, componentData] of sliceEntries) {
499
+ try {
500
+ if (componentData.html !== undefined && !this.templates.has(componentName)) {
501
+ const template = document.createElement('template');
502
+ template.innerHTML = componentData.html || '';
503
+ this.templates.set(componentName, template);
504
+ }
505
+
506
+ if (componentData.css !== undefined && !this.requestedStyles.has(componentName)) {
507
+ if (window.slice && window.slice.stylesManager) {
508
+ window.slice.stylesManager.registerComponentStyles(componentName, componentData.css || '');
509
+ this.requestedStyles.add(componentName);
510
+ }
511
+ }
512
+
513
+ if (componentData.class && !this.classes.has(componentName)) {
514
+ const registeredName = componentData.isFramework
515
+ ? `Framework/Structural/${componentName}`
516
+ : componentName;
517
+ this.classes.set(registeredName, componentData.class);
518
+ if (componentName === 'Loading') {
519
+ slice.logger.logInfo('Controller', `Bundle class registered: Loading (registeredName=${registeredName})`);
520
+ }
521
+ if (componentName === 'InputSearchDocs' || componentName === 'MainMenu') {
522
+ slice.logger.logInfo('Controller', `Bundle class registered: ${componentName}`);
523
+ }
524
+ }
525
+ } catch (error) {
526
+ slice.logger.error('Controller', `Failed to register component ${componentName}`, error);
527
+ }
528
+ }
529
+
530
+ index += chunkSize;
531
+ if (index < entries.length) {
532
+ if (typeof requestIdleCallback === 'function') {
533
+ requestIdleCallback(processChunk);
534
+ } else {
535
+ setTimeout(processChunk, 0);
536
+ }
537
+ return;
538
+ }
539
+
540
+ slice.logger.info('Controller', `Bundle registration completed: ${metadata.componentCount} components processed`);
541
+ resolve(true);
542
+ } catch (error) {
543
+ slice.logger.error('Controller', 'Fatal error in registerBundle chunk processing', error);
544
+ resolve(false);
545
+ }
546
+ };
547
+
548
+ processChunk();
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Validates bundle structure before registering.
554
+ * @param {object} bundle
555
+ * @returns {{isValid: boolean, error?: string}}
556
+ */
557
+ validateBundle(bundle) {
558
+ if (!bundle || typeof bundle !== 'object') {
559
+ return { isValid: false, error: 'Bundle payload is invalid' };
560
+ }
561
+
562
+ if (!bundle.metadata || typeof bundle.metadata !== 'object') {
563
+ return { isValid: false, error: 'Bundle metadata missing' };
564
+ }
565
+
566
+ if (!bundle.components || typeof bundle.components !== 'object') {
567
+ return { isValid: false, error: 'Bundle components missing' };
568
+ }
569
+
570
+ if (typeof bundle.metadata.componentCount !== 'number') {
571
+ return { isValid: false, error: 'Bundle metadata missing componentCount' };
572
+ }
573
+
574
+ if (bundle.metadata.componentCount !== Object.keys(bundle.components).length) {
575
+ return { isValid: false, error: 'Bundle component count mismatch' };
576
+ }
577
+
578
+ const maxComponents = 5000;
579
+ if (bundle.metadata.componentCount > maxComponents) {
580
+ return { isValid: false, error: 'Bundle component count exceeds limit' };
581
+ }
582
+
583
+ return { isValid: true };
584
+ }
585
+
586
+ /**
587
+ * 📦 Determines which bundle to load for a component
588
+ */
589
+ getBundleForComponent(componentName) {
590
+ if (!this.bundleConfig?.bundles) return null;
591
+
592
+ // Check if component is in critical bundle
593
+ if (this.bundleConfig.bundles.critical?.components?.includes(componentName)) {
594
+ return 'critical';
595
+ }
596
+
597
+ // Find component in route bundles
598
+ if (this.bundleConfig.bundles.routes) {
599
+ for (const [bundleName, bundleInfo] of Object.entries(this.bundleConfig.bundles.routes)) {
600
+ if (bundleInfo.components?.includes(componentName)) {
601
+ return bundleName;
602
+ }
603
+ }
604
+ }
605
+
606
+ return null;
607
+ }
608
+
609
+ /**
610
+ * 📦 Checks if a component is available from loaded bundles
611
+ */
612
+ isComponentFromBundle(componentName) {
613
+ if (!this.bundleConfig?.bundles) return false;
614
+
615
+ // Check critical bundle
616
+ if (this.bundleConfig.bundles.critical?.components?.includes(componentName)) {
617
+ return this.criticalBundleLoaded;
618
+ }
619
+
620
+ // Check route bundles
621
+ if (this.bundleConfig.bundles.routes) {
622
+ for (const [bundleName, bundleInfo] of Object.entries(this.bundleConfig.bundles.routes)) {
623
+ if (bundleInfo.components?.includes(componentName)) {
624
+ return this.loadedBundles.has(bundleName);
625
+ }
626
+ }
627
+ }
628
+
629
+ return false;
630
+ }
631
+
632
+ /**
633
+ * 📦 Gets component data from loaded bundles
634
+ */
635
+ getComponentFromBundle(componentName) {
636
+ if (!this.bundleConfig?.bundles) return null;
637
+
638
+ // Find component in any loaded bundle
639
+ const allBundles = [
640
+ { name: 'critical', data: this.bundleConfig.bundles.critical },
641
+ ...Object.entries(this.bundleConfig.bundles.routes || {}).map(([name, data]) => ({ name, data })),
642
+ ];
643
+
644
+ for (const { name: bundleName, data: bundleData } of allBundles) {
645
+ if (bundleData?.components?.includes(componentName) && this.loadedBundles.has(bundleName)) {
646
+ // Find the bundle file and extract component data
647
+ // This is a simplified version - in practice you'd need to access the loaded bundle data
648
+ return { bundleName, componentName };
649
+ }
650
+ }
651
+
652
+ return null;
653
+ }
654
+
655
+ logActiveComponents() {
656
+ this.activeComponents.forEach((component) => {
657
+ let parent = component.parentComponent;
658
+ let parentName = parent ? parent.constructor.name : null;
659
+ });
660
+ }
661
+
662
+ getTopParentsLinkedToActiveComponents() {
663
+ let topParentsLinkedToActiveComponents = new Map();
664
+ this.activeComponents.forEach((component) => {
665
+ let parent = component.parentComponent;
666
+ while (parent && parent.parentComponent) {
667
+ parent = parent.parentComponent;
668
+ }
669
+ if (!topParentsLinkedToActiveComponents.has(parent)) {
670
+ topParentsLinkedToActiveComponents.set(parent, []);
671
+ }
672
+ topParentsLinkedToActiveComponents.get(parent).push(component);
673
+ });
674
+ return topParentsLinkedToActiveComponents;
675
+ }
676
+
677
+ verifyComponentIds(component) {
678
+ const htmlId = component.id;
679
+
680
+ if (htmlId && htmlId.trim() !== '') {
681
+ if (this.activeComponents.has(htmlId)) {
682
+ slice.logger.logError(
683
+ 'Controller',
684
+ `A component with the same html id attribute is already registered: ${htmlId}`
685
+ );
686
+ return false;
687
+ }
688
+ }
689
+
690
+ let sliceId = component.sliceId;
691
+
692
+ if (sliceId && sliceId.trim() !== '') {
693
+ if (this.activeComponents.has(sliceId)) {
694
+ slice.logger.logError(
695
+ 'Controller',
696
+ `A component with the same slice id attribute is already registered: ${sliceId}`
697
+ );
698
+ return false;
699
+ }
700
+ } else {
701
+ sliceId = `${component.constructor.name[0].toLowerCase()}${component.constructor.name.slice(1)}-${this.idCounter}`;
702
+ component.sliceId = sliceId;
703
+ this.idCounter++;
704
+ }
705
+
706
+ component.sliceId = sliceId;
707
+ return true;
708
+ }
709
+
710
+ /**
711
+ * Registra un componente y actualiza el índice de relaciones padre-hijo
712
+ * 🚀 OPTIMIZADO: Ahora mantiene childrenIndex y precalcula profundidad
713
+ */
714
+ registerComponent(component, parent = null) {
715
+ component.parentComponent = parent;
716
+
717
+ // 🚀 OPTIMIZACIÓN: Precalcular y guardar profundidad
718
+ component._depth = parent ? (parent._depth || 0) + 1 : 0;
719
+
720
+ // Registrar en activeComponents
721
+ this.activeComponents.set(component.sliceId, component);
722
+
723
+ // Exponer sliceId como atributo HTML para búsqueda por DOM (destroyByContainer, etc.)
724
+ if (typeof component.setAttribute === 'function') {
725
+ component.setAttribute('slice-id', component.sliceId);
726
+ }
727
+
728
+ // Envolver update() propio (si lo define) con re-entrada + limpieza por
729
+ // liveness. Se hace aquí (post-registro) para que `alive()` funcione y para
730
+ // que un update() llamado dentro de init() corra sin envolver. (RFC §3.6)
731
+ this.installUpdate(component);
732
+
733
+ // 🚀 OPTIMIZACIÓN: Actualizar índice inverso de hijos
734
+ if (parent) {
735
+ if (!this.childrenIndex.has(parent.sliceId)) {
736
+ this.childrenIndex.set(parent.sliceId, new Set());
737
+ }
738
+ this.childrenIndex.get(parent.sliceId).add(component.sliceId);
739
+ }
740
+
741
+ return true;
742
+ }
743
+
744
+ registerComponentsRecursively(component, parent = null) {
745
+ // Assign parent if not already set
746
+ if (!component.parentComponent) {
747
+ component.parentComponent = parent;
748
+ }
749
+
750
+ // Recursively assign parent to children
751
+ component.querySelectorAll('*').forEach((child) => {
752
+ if (child.tagName.startsWith('SLICE-')) {
753
+ // Only the call that establishes the DIRECT parent link feeds the
754
+ // index — the depth-first recursion sets a node's parent before an
755
+ // outer ancestor's forEach reaches it, so `component` here is the
756
+ // immediate enclosing component.
757
+ if (!child.parentComponent) {
758
+ child.parentComponent = component;
759
+ // 🔁 Maintain childrenIndex so destroyComponent(parent) cascades
760
+ // to children built via slice.build (which registers them WITHOUT
761
+ // a parent, leaving the index otherwise empty). Without this, a
762
+ // parent's destroy never finds — and never cleans up — its children.
763
+ if (child.sliceId && component.sliceId) {
764
+ if (!this.childrenIndex.has(component.sliceId)) {
765
+ this.childrenIndex.set(component.sliceId, new Set());
766
+ }
767
+ this.childrenIndex.get(component.sliceId).add(child.sliceId);
768
+ child._depth = (component._depth || 0) + 1;
769
+ }
770
+ }
771
+ this.registerComponentsRecursively(child, component);
772
+ }
773
+ });
774
+ }
775
+
776
+ /**
777
+ * Envuelve el `update()` propio de un componente (si lo define) para darle, de
778
+ * forma transparente, dos garantías que el dev no tiene que escribir:
779
+ *
780
+ * - Re-entrada (serializa + coalesce, "gana el último"): si llega otro
781
+ * `update()` mientras uno async está en vuelo, se guarda SOLO el más nuevo y
782
+ * se corre una vez al terminar. Nunca dos updates pisan el DOM a la vez.
783
+ * - Liveness: si el componente se destruye a mitad de un `update()` async, al
784
+ * resolver se limpian (`destroyByContainer`) los hijos que ese update alcanzó
785
+ * a construir, evitando fugas. El destroy NO se difiere (rompería el reuso de
786
+ * sliceId); se limpia después.
787
+ *
788
+ * Componentes sin `update()` no se tocan (no hay método fantasma). Ver RFC
789
+ * §3.5 / §3.6 / §8.
790
+ * @param {HTMLElement} component
791
+ * @returns {void}
792
+ */
793
+ installUpdate(component) {
794
+ if (typeof component.update !== 'function') return;
795
+
796
+ const controller = this;
797
+ const inner = component.update.bind(component);
798
+ const alive = () => controller.activeComponents.get(component.sliceId) === component;
799
+
800
+ let running = null;
801
+ let pending;
802
+ let hasPending = false;
803
+
804
+ component.update = function (next) {
805
+ if (!alive()) return; // ya destruido: nada
806
+ const args = next ?? component.props; // sin args -> this.props (lo mantiene el Router)
807
+
808
+ if (running) { // coalesce: conserva solo lo último
809
+ pending = args;
810
+ hasPending = true;
811
+ return running;
812
+ }
813
+
814
+ running = Promise.resolve(inner(args))
815
+ .catch((error) => slice.logger.logError(component.constructor.name, 'update() failed', error))
816
+ .finally(() => {
817
+ running = null;
818
+ if (!alive()) { // destruido a mitad del await: limpiar lo construido
819
+ controller.destroyByContainer(component);
820
+ return; // y NO correr el pendiente
821
+ }
822
+ if (hasPending) {
823
+ hasPending = false;
824
+ const next = pending;
825
+ pending = undefined;
826
+ component.update(next);
827
+ }
828
+ });
829
+
830
+ return running;
831
+ };
832
+ }
833
+
834
+ /**
835
+ * Get a registered component by sliceId.
836
+ * @param {string} sliceId
837
+ * @returns {HTMLElement|undefined}
838
+ */
839
+ getComponent(sliceId) {
840
+ return this.activeComponents.get(sliceId);
841
+ }
842
+
843
+ /**
844
+ * Get-or-create a single instance keyed by sliceId, deduplicating concurrent
845
+ * builds. Returns the existing instance if already registered, the in-flight
846
+ * promise if a build is underway, or otherwise memoizes a fresh build via
847
+ * `builder` (an injected closure — the controller never builds by itself).
848
+ *
849
+ * The in-flight promise is removed once it settles, so a failed build (which
850
+ * resolves to `null` and never registers in activeComponents) can be retried
851
+ * by a later call and never poisons the registry.
852
+ *
853
+ * @param {string} sliceId
854
+ * @param {() => Promise<any>} builder
855
+ * @returns {any|Promise<any>} instance (sync) or Promise<instance>
856
+ */
857
+ getOrCreate(sliceId, builder) {
858
+ const existing = this.activeComponents.get(sliceId);
859
+ if (existing) return existing;
860
+
861
+ const pending = this._pendingBuilds.get(sliceId);
862
+ if (pending) return pending;
863
+
864
+ const promise = Promise.resolve(builder())
865
+ .finally(() => this._pendingBuilds.delete(sliceId));
866
+ this._pendingBuilds.set(sliceId, promise);
867
+ return promise;
868
+ }
869
+
870
+ loadTemplateToComponent(component) {
871
+ const className = component.constructor.name;
872
+ const template = this.templates.get(className);
873
+
874
+ if (!template) {
875
+ slice.logger.error('Controller', `Template not found for component: ${className}`);
876
+ return;
877
+ }
878
+
879
+ component.innerHTML = template.innerHTML;
880
+ return component;
881
+ }
882
+
883
+ getComponentCategory(componentSliceId) {
884
+ return this.componentCategories.get(componentSliceId);
885
+ }
886
+
887
+ /**
888
+ * Fetch component resources (html, css, styles, theme).
889
+ * @param {string} componentName
890
+ * @param {'html'|'css'|'theme'|'styles'} resourceType
891
+ * @param {string} [componentCategory]
892
+ * @param {string} [customPath]
893
+ * @returns {Promise<string>}
894
+ */
895
+ async fetchText(componentName, resourceType, componentCategory, customPath) {
896
+ try {
897
+ const baseUrl = window.location.origin;
898
+ let path;
899
+
900
+ if (!componentCategory) {
901
+ componentCategory = this.componentCategories.get(componentName);
902
+ }
903
+
904
+ let isVisual = resourceType === 'html' || resourceType === 'css';
905
+
906
+ if (isVisual) {
907
+ if (slice.paths.components[componentCategory]) {
908
+ path = `${baseUrl}${slice.paths.components[componentCategory].path}/${componentName}`;
909
+ resourceType === 'html' ? (path += `/${componentName}.html`) : (path += `/${componentName}.css`);
910
+ } else {
911
+ if (componentCategory === 'Structural') {
912
+ path = `${baseUrl}/Slice/Components/Structural/${componentName}`;
913
+ resourceType === 'html' ? (path += `/${componentName}.html`) : (path += `/${componentName}.css`);
914
+ } else {
915
+ throw new Error(`Component category '${componentCategory}' not found in paths configuration`);
916
+ }
917
+ }
918
+ }
919
+
920
+ if (resourceType === 'theme') {
921
+ path = `${baseUrl}${slice.paths.themes}/${componentName}.css`;
922
+ }
923
+
924
+ if (resourceType === 'styles') {
925
+ path = `${baseUrl}${slice.paths.styles}/${componentName}.css`;
926
+ }
927
+
928
+ if (customPath) {
929
+ path = customPath;
930
+ }
931
+
932
+ slice.logger.logInfo('Controller', `Fetching ${resourceType} from: ${path}`);
933
+
934
+ const response = await fetch(path);
935
+
936
+ if (!response.ok) {
937
+ throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
938
+ }
939
+
940
+ const content = await response.text();
941
+ slice.logger.logInfo('Controller', `Successfully fetched ${resourceType} for ${componentName}`);
942
+ return content;
943
+ } catch (error) {
944
+ slice.logger.logError('Controller', `Error fetching ${resourceType} for component ${componentName}:`, error);
945
+ throw error;
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Apply props to a component using static defaults and setters.
951
+ * @param {HTMLElement} component
952
+ * @param {Object} props
953
+ * @returns {void}
954
+ */
955
+ setComponentProps(component, props) {
956
+ const ComponentClass = component.constructor;
957
+ const componentName = ComponentClass.name;
958
+
959
+ // ¿Refresco o build? En el constructor el componente aún NO está registrado
960
+ // bajo su sliceId (el framework lo registra después) -> build. Si ya está
961
+ // registrado como su propia instancia -> refresco. (RFC §3.8 — autodetección)
962
+ const isRefresh = !!component.sliceId && this.activeComponents.get(component.sliceId) === component;
963
+
964
+ // Defaults: solo en build. En refresco NO, para no pisar valores ya modificados.
965
+ if (ComponentClass.props && !isRefresh) {
966
+ this.applyDefaultProps(component, ComponentClass.props, props);
967
+ }
968
+
969
+ // Validar solo en desarrollo. En refresco (patch parcial) no se exige `required`.
970
+ if (ComponentClass.props && !slice.isProduction()) {
971
+ this.validatePropsInDevelopment(ComponentClass, props, componentName, { checkRequired: !isRefresh });
972
+ }
973
+
974
+ // Aplicar props. En build reseteamos el backing field para forzar el setter
975
+ // (primer render). En refresco NO lo reseteamos, así los diff-guards cortan.
976
+ for (const prop in props) {
977
+ if (!isRefresh) component[`_${prop}`] = null;
978
+ component[prop] = props[prop];
979
+ }
980
+ }
981
+
982
+ getComponentPropsForDebugger(component) {
983
+ const ComponentClass = component.constructor;
984
+
985
+ if (ComponentClass.props) {
986
+ return {
987
+ availableProps: Object.keys(ComponentClass.props),
988
+ propsConfig: ComponentClass.props,
989
+ usedProps: this.extractUsedProps(component, ComponentClass.props),
990
+ };
991
+ } else {
992
+ return {
993
+ availableProps: this.extractUsedProps(component),
994
+ propsConfig: null,
995
+ usedProps: this.extractUsedProps(component),
996
+ };
997
+ }
998
+ }
999
+
1000
+ applyDefaultProps(component, staticProps, providedProps) {
1001
+ Object.entries(staticProps).forEach(([prop, config]) => {
1002
+ if (config.default !== undefined && !(prop in (providedProps || {}))) {
1003
+ component[`_${prop}`] = null;
1004
+ component[prop] = config.default;
1005
+ }
1006
+ });
1007
+ }
1008
+
1009
+ validatePropsInDevelopment(ComponentClass, providedProps, componentName, options = {}) {
1010
+ const { checkRequired = true } = options;
1011
+ const staticProps = ComponentClass.props;
1012
+ const usedProps = Object.keys(providedProps || {});
1013
+
1014
+ const availableProps = Object.keys(staticProps);
1015
+ const unknownProps = usedProps.filter((prop) => !availableProps.includes(prop));
1016
+
1017
+ if (unknownProps.length > 0) {
1018
+ slice.logger.logWarning(
1019
+ 'PropsValidator',
1020
+ `${componentName}: Unknown props [${unknownProps.join(', ')}]. Available: [${availableProps.join(', ')}]`
1021
+ );
1022
+ }
1023
+
1024
+ // En un refresco (patch parcial) se omiten props a propósito: no exigir required.
1025
+ if (checkRequired) {
1026
+ const requiredProps = Object.entries(staticProps)
1027
+ .filter(([_, config]) => config.required)
1028
+ .map(([prop, _]) => prop);
1029
+
1030
+ const missingRequired = requiredProps.filter((prop) => !(prop in (providedProps || {})));
1031
+ if (missingRequired.length > 0) {
1032
+ slice.logger.logError(componentName, `Missing required props: [${missingRequired.join(', ')}]`);
1033
+ }
1034
+ }
1035
+
1036
+ const invalidAllowedValueProps = collectInvalidAllowedValueProps(staticProps, providedProps);
1037
+ invalidAllowedValueProps.forEach(({ propName, value, allowedValues }) => {
1038
+ const safeValue = (() => { try { return JSON.stringify(value); } catch { return String(value); } })();
1039
+ slice.logger.error(
1040
+ componentName,
1041
+ `Invalid value for prop "${propName}": ${safeValue}. Allowed values: ${formatAllowedValuesForLog(allowedValues)}`
1042
+ );
1043
+ });
1044
+ }
1045
+
1046
+ extractUsedProps(component, staticProps = null) {
1047
+ const usedProps = {};
1048
+
1049
+ if (staticProps) {
1050
+ Object.keys(staticProps).forEach((prop) => {
1051
+ if (component[prop] !== undefined) {
1052
+ usedProps[prop] = component[prop];
1053
+ }
1054
+ });
1055
+ } else {
1056
+ Object.getOwnPropertyNames(component).forEach((key) => {
1057
+ if (key.startsWith('_') && key !== '_isActive') {
1058
+ const propName = key.substring(1);
1059
+ usedProps[propName] = component[propName];
1060
+ }
1061
+ });
1062
+ }
1063
+
1064
+ return usedProps;
1065
+ }
1066
+
1067
+ // ============================================================================
1068
+ // 🚀 MÉTODOS DE DESTRUCCIÓN OPTIMIZADOS
1069
+ // ============================================================================
1070
+
1071
+ /**
1072
+ * Encuentra recursivamente todos los hijos de un componente
1073
+ * 🚀 OPTIMIZADO: O(m) en lugar de O(n*d) - usa childrenIndex
1074
+ * @param {string} parentSliceId - sliceId del componente padre
1075
+ * @param {Set<string>} collected - Set de sliceIds ya recolectados
1076
+ * @returns {Set<string>} Set de todos los sliceIds de componentes hijos
1077
+ */
1078
+ findAllChildComponents(parentSliceId, collected = new Set()) {
1079
+ // 🚀 Buscar directamente en el índice: O(1)
1080
+ const children = this.childrenIndex.get(parentSliceId);
1081
+
1082
+ if (!children) return collected;
1083
+
1084
+ // 🚀 Iterar solo los hijos directos: O(k) donde k = número de hijos
1085
+ for (const childSliceId of children) {
1086
+ collected.add(childSliceId);
1087
+ // Recursión solo sobre hijos, no todos los componentes
1088
+ this.findAllChildComponents(childSliceId, collected);
1089
+ }
1090
+
1091
+ return collected;
1092
+ }
1093
+
1094
+ /**
1095
+ * Encuentra recursivamente todos los componentes dentro de un contenedor DOM
1096
+ * Útil para destroyByContainer cuando no tenemos el sliceId del padre
1097
+ * @param {HTMLElement} container - Elemento contenedor
1098
+ * @param {Set<string>} collected - Set de sliceIds ya recolectados
1099
+ * @returns {Set<string>} Set de todos los sliceIds encontrados
1100
+ */
1101
+ findAllNestedComponentsInContainer(container, collected = new Set()) {
1102
+ // Buscar todos los elementos con slice-id en el contenedor
1103
+ const sliceComponents = container.querySelectorAll('[slice-id]');
1104
+
1105
+ sliceComponents.forEach((element) => {
1106
+ const sliceId = element.getAttribute('slice-id') || element.sliceId;
1107
+ if (sliceId && this.activeComponents.has(sliceId)) {
1108
+ collected.add(sliceId);
1109
+ // 🚀 Usar índice para buscar hijos recursivamente
1110
+ this.findAllChildComponents(sliceId, collected);
1111
+ }
1112
+ });
1113
+
1114
+ return collected;
1115
+ }
1116
+
1117
+ /**
1118
+ * Destruye uno o múltiples componentes DE FORMA RECURSIVA
1119
+ * 🚀 OPTIMIZADO: O(m log m) en lugar de O(n*d + m log m)
1120
+ * @param {HTMLElement|Array<HTMLElement>|string|Array<string>} components
1121
+ * @returns {number} Cantidad de componentes destruidos (incluyendo hijos)
1122
+ */
1123
+ destroyComponent(components) {
1124
+ const toDestroy = Array.isArray(components) ? components : [components];
1125
+ const allSliceIdsToDestroy = new Set();
1126
+
1127
+ // PASO 1: Recolectar todos los componentes padres y sus hijos recursivamente
1128
+ for (const item of toDestroy) {
1129
+ let sliceId = null;
1130
+
1131
+ if (typeof item === 'string') {
1132
+ if (!this.activeComponents.has(item)) {
1133
+ slice.logger.logWarning('Controller', `Component with sliceId "${item}" not found`);
1134
+ continue;
1135
+ }
1136
+ sliceId = item;
1137
+ } else if (item && item.sliceId) {
1138
+ sliceId = item.sliceId;
1139
+ } else {
1140
+ slice.logger.logWarning('Controller', `Invalid component or sliceId provided to destroyComponent`);
1141
+ continue;
1142
+ }
1143
+
1144
+ allSliceIdsToDestroy.add(sliceId);
1145
+
1146
+ // 🚀 OPTIMIZADO: Usa childrenIndex en lugar de recorrer todos los componentes
1147
+ this.findAllChildComponents(sliceId, allSliceIdsToDestroy);
1148
+ }
1149
+
1150
+ // PASO 2: Ordenar por profundidad (más profundos primero)
1151
+ // 🚀 OPTIMIZADO: Usa _depth precalculada en lugar de calcularla cada vez
1152
+ const sortedSliceIds = Array.from(allSliceIdsToDestroy).sort((a, b) => {
1153
+ const compA = this.activeComponents.get(a);
1154
+ const compB = this.activeComponents.get(b);
1155
+
1156
+ if (!compA || !compB) return 0;
1157
+
1158
+ // 🚀 O(1) en lugar de O(d) - usa profundidad precalculada
1159
+ return (compB._depth || 0) - (compA._depth || 0);
1160
+ });
1161
+
1162
+ let destroyedCount = 0;
1163
+
1164
+ // PASO 3: Destruir en orden correcto (hijos antes que padres)
1165
+ for (const sliceId of sortedSliceIds) {
1166
+ const component = this.activeComponents.get(sliceId);
1167
+
1168
+ if (!component) continue;
1169
+
1170
+ // Ejecutar hook beforeDestroy si existe
1171
+ if (typeof component.beforeDestroy === 'function') {
1172
+ try {
1173
+ component.beforeDestroy();
1174
+ } catch (error) {
1175
+ slice.logger.logError('Controller', `Error in beforeDestroy for ${sliceId}`, error);
1176
+ }
1177
+ }
1178
+
1179
+ // Limpiar suscripciones de eventos del componente
1180
+ if (slice.events) {
1181
+ slice.events.cleanupComponent(sliceId);
1182
+ }
1183
+
1184
+ // 🚀 Limpiar del índice de hijos
1185
+ this.childrenIndex.delete(sliceId);
1186
+
1187
+ // Si tiene padre, remover de la lista de hijos del padre
1188
+ if (component.parentComponent) {
1189
+ const parentChildren = this.childrenIndex.get(component.parentComponent.sliceId);
1190
+ if (parentChildren) {
1191
+ parentChildren.delete(sliceId);
1192
+ // Si el padre no tiene más hijos, eliminar entrada vacía
1193
+ if (parentChildren.size === 0) {
1194
+ this.childrenIndex.delete(component.parentComponent.sliceId);
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ // Eliminar del mapa de componentes activos
1200
+ this.activeComponents.delete(sliceId);
1201
+
1202
+ // Remover del DOM si está conectado
1203
+ if (component.isConnected) {
1204
+ component.remove();
1205
+ }
1206
+
1207
+ destroyedCount++;
1208
+ }
1209
+
1210
+ if (destroyedCount > 0) {
1211
+ slice.logger.logInfo('Controller', `Destroyed ${destroyedCount} component(s) recursively`);
1212
+ }
1213
+
1214
+ return destroyedCount;
1215
+ }
1216
+
1217
+ /**
1218
+ * Destruye todos los componentes Slice dentro de un contenedor (RECURSIVO)
1219
+ * 🚀 OPTIMIZADO: Usa el índice inverso para búsqueda rápida
1220
+ * @param {HTMLElement} container - Elemento contenedor
1221
+ * @returns {number} Cantidad de componentes destruidos
1222
+ */
1223
+ destroyByContainer(container) {
1224
+ if (!container) {
1225
+ slice.logger.logWarning('Controller', 'No container provided to destroyByContainer');
1226
+ return 0;
1227
+ }
1228
+
1229
+ // 🚀 Recolectar componentes usando índice optimizado
1230
+ const allSliceIds = this.findAllNestedComponentsInContainer(container);
1231
+
1232
+ if (allSliceIds.size === 0) {
1233
+ return 0;
1234
+ }
1235
+
1236
+ // Destruir usando el método principal optimizado
1237
+ const count = this.destroyComponent(Array.from(allSliceIds));
1238
+
1239
+ if (count > 0) {
1240
+ slice.logger.logInfo('Controller', `Destroyed ${count} component(s) from container (including nested)`);
1241
+ }
1242
+
1243
+ return count;
1244
+ }
1245
+
1246
+ /**
1247
+ * Destruye componentes cuyos sliceId coincidan con un patrón (RECURSIVO)
1248
+ * 🚀 OPTIMIZADO: Usa destrucción optimizada
1249
+ * @param {string|RegExp} pattern - Patrón a buscar
1250
+ * @returns {number} Cantidad de componentes destruidos
1251
+ */
1252
+ destroyByPattern(pattern) {
1253
+ const componentsToDestroy = [];
1254
+ const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern);
1255
+
1256
+ for (const [sliceId, component] of this.activeComponents) {
1257
+ if (regex.test(sliceId)) {
1258
+ componentsToDestroy.push(component);
1259
+ }
1260
+ }
1261
+
1262
+ if (componentsToDestroy.length === 0) {
1263
+ return 0;
1264
+ }
1265
+
1266
+ const count = this.destroyComponent(componentsToDestroy);
1267
+
1268
+ if (count > 0) {
1269
+ slice.logger.logInfo(
1270
+ 'Controller',
1271
+ `Destroyed ${count} component(s) matching pattern: ${pattern} (including nested)`
1272
+ );
1273
+ }
1274
+
1275
+ return count;
1276
+ }
1277
+
1278
+ // ============================================================
1279
+ // 🔍 LEAK DETECTION (dev-only)
1280
+ // A component is a candidate leak when it stays registered in
1281
+ // activeComponents while detached from the live DOM and was not
1282
+ // intentionally cached (Route/MultiRoute/Router). Read-only.
1283
+ // ============================================================
1284
+
1285
+ /**
1286
+ * Register a predicate that excludes a detached component from being flagged
1287
+ * as a leak (e.g. caches that intentionally keep instances off-DOM).
1288
+ * @param {(component: HTMLElement) => boolean} predicate
1289
+ */
1290
+ registerLeakExclusion(predicate) {
1291
+ if (typeof predicate === 'function') {
1292
+ this._leakExclusions.push(predicate);
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Is this detached component intentionally retained (not a leak)?
1298
+ * @param {HTMLElement} component
1299
+ * @returns {boolean}
1300
+ * @private
1301
+ */
1302
+ _isExcludedOrphan(component) {
1303
+ // (1) Cache-owner marker — Route/MultiRoute flag their cached instances.
1304
+ if (component.__sliceCached === true) return true;
1305
+ // (2) Router-managed instances are reused across navigation by sliceId.
1306
+ if (typeof component.sliceId === 'string' && /^route-/.test(component.sliceId)) return true;
1307
+ // (3) App-provided predicates.
1308
+ for (const predicate of this._leakExclusions) {
1309
+ try {
1310
+ if (predicate(component)) return true;
1311
+ } catch (error) {
1312
+ slice.logger.logError('Controller', 'Leak exclusion predicate threw', error);
1313
+ }
1314
+ }
1315
+ return false;
1316
+ }
1317
+
1318
+ /**
1319
+ * Does an ancestor explain this component's detachment? If so we report only
1320
+ * the detached root, not every descendant of a removed/cached subtree.
1321
+ * @param {HTMLElement} component
1322
+ * @param {Set<string>} detachedIds
1323
+ * @returns {boolean}
1324
+ * @private
1325
+ */
1326
+ _hasDetachedAncestor(component, detachedIds) {
1327
+ let parent = component.parentComponent;
1328
+ while (parent) {
1329
+ if ((parent.sliceId && detachedIds.has(parent.sliceId)) || this._isExcludedOrphan(parent)) {
1330
+ return true;
1331
+ }
1332
+ parent = parent.parentComponent;
1333
+ }
1334
+ return false;
1335
+ }
1336
+
1337
+ /**
1338
+ * Build a human-readable retain path (component → parent → …) for diagnosis.
1339
+ * @param {HTMLElement} component
1340
+ * @returns {string[]}
1341
+ * @private
1342
+ */
1343
+ _retainPath(component) {
1344
+ const path = [];
1345
+ let node = component;
1346
+ let guard = 0;
1347
+ while (node && guard++ < 50) {
1348
+ path.push(`${node.constructor?.name || 'Unknown'}(${node.sliceId || '?'})`);
1349
+ node = node.parentComponent;
1350
+ }
1351
+ return path;
1352
+ }
1353
+
1354
+ /**
1355
+ * Find candidate leaks: components registered in activeComponents but detached
1356
+ * from the live DOM and not intentionally cached. Pure read-only.
1357
+ * @returns {Array<{ sliceId: string, name: string, reason: string, retainPath: string[] }>}
1358
+ */
1359
+ findOrphans() {
1360
+ const detached = [];
1361
+ for (const [sliceId, component] of this.activeComponents) {
1362
+ if (component instanceof HTMLElement && !component.isConnected) {
1363
+ detached.push([sliceId, component]);
1364
+ }
1365
+ }
1366
+
1367
+ const detachedIds = new Set(detached.map(([sliceId]) => sliceId));
1368
+ const orphans = [];
1369
+
1370
+ for (const [sliceId, component] of detached) {
1371
+ if (this._isExcludedOrphan(component)) continue;
1372
+ if (this._hasDetachedAncestor(component, detachedIds)) continue;
1373
+ orphans.push({
1374
+ sliceId,
1375
+ name: component.constructor?.name || 'Unknown',
1376
+ reason: 'registered but detached from DOM without destroy',
1377
+ retainPath: this._retainPath(component)
1378
+ });
1379
+ }
1380
+
1381
+ return orphans;
1382
+ }
1383
+
1384
+ /**
1385
+ * Record an activeComponents.size sample for the monotonic-growth heuristic.
1386
+ * Called by the Leak Inspector on each refresh while it is open.
1387
+ */
1388
+ sampleComponentGrowth() {
1389
+ this._growthSamples.push(this.activeComponents.size);
1390
+ if (this._growthSamples.length > 20) this._growthSamples.shift();
1391
+ }
1392
+
1393
+ /**
1394
+ * Heuristic: has the active component count grown without ever shrinking
1395
+ * across the sampled window? Suggests an accumulation leak.
1396
+ * @returns {boolean}
1397
+ */
1398
+ isGrowthMonotonic() {
1399
+ const samples = this._growthSamples;
1400
+ if (samples.length < 5) return false;
1401
+ for (let i = 1; i < samples.length; i++) {
1402
+ if (samples[i] < samples[i - 1]) return false;
1403
+ }
1404
+ return samples[samples.length - 1] > samples[0];
1405
+ }
1406
+ }