slicejs-web-framework 4.0.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/LICENSE +21 -21
  3. package/README.md +97 -97
  4. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  5. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
  6. package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
  7. package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
  8. package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
  9. package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
  10. package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
  11. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
  12. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
  13. package/Slice/Components/Structural/Logger/Log.js +10 -10
  14. package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
  15. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  16. package/Slice/Components/Structural/Router/Router.js +810 -793
  17. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  18. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -162
  19. package/Slice/Slice.js +708 -685
  20. package/Slice/tests/build-bundled-component-without-category.test.js +103 -103
  21. package/Slice/tests/build-js-only-visual-components.test.js +144 -144
  22. package/Slice/tests/build-singleton.test.js +244 -244
  23. package/Slice/tests/bundle-v2-runtime-contract.test.js +747 -738
  24. package/Slice/tests/context-patch-use.test.js +95 -95
  25. package/Slice/tests/destroy-cascade.test.js +215 -215
  26. package/Slice/tests/event-emitters.test.js +79 -79
  27. package/Slice/tests/event-registry.test.js +141 -141
  28. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  29. package/Slice/tests/leak-orphans.test.js +131 -131
  30. package/Slice/tests/props-allowed-values-validation.test.js +119 -119
  31. package/Slice/tests/public-env-runtime-accessors.test.js +44 -44
  32. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  33. package/Slice/tests/router-loading-finally.test.js +68 -68
  34. package/api/framework/devDepsOptimizer.js +335 -335
  35. package/api/framework/express.js +3 -3
  36. package/api/framework/precompressedStatic.js +93 -93
  37. package/api/framework/publicEnvResolver.js +123 -123
  38. package/api/framework/securityMiddleware.js +197 -197
  39. package/api/framework/server.js +411 -411
  40. package/api/index.js +7 -7
  41. package/api/tests/dev-deps-optimizer.test.js +137 -137
  42. package/api/tests/dev-server-external.test.js +56 -56
  43. package/api/tests/precompressed-static.test.js +119 -119
  44. package/api/tests/public-env-resolver.test.js +193 -193
  45. package/package.json +49 -49
  46. package/sliceConfig.schema.json +122 -122
  47. package/src/App/index.html +69 -69
  48. package/src/App/index.js +20 -20
  49. package/src/App/style.css +11 -11
  50. package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
  51. package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
  52. package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
  53. package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
  54. package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
  55. package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
  56. package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
  57. package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
  58. package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
  59. package/src/Components/components.js +8 -8
  60. package/src/public/Styles/sliceStyles.css +34 -34
  61. package/src/public/Themes/Dark.css +42 -42
  62. package/src/public/Themes/Light.css +31 -31
  63. package/src/public/Themes/Slice.css +47 -47
  64. package/src/public/manifest.json +15 -15
  65. package/src/public/robots.txt +4 -4
  66. package/src/public/sitemap.xml +8 -8
  67. package/src/routes.js +9 -9
  68. package/src/sliceConfig.json +72 -72
  69. package/tests/router-build-failure.test.js +125 -0
  70. package/tests/router-emit-ordering.test.js +63 -63
  71. package/types/index.d.ts +219 -219
@@ -1,793 +1,810 @@
1
- /**
2
- * @typedef {Object} RouteConfig
3
- * @property {string} path
4
- * @property {string} component
5
- * @property {RouteConfig[]} [children]
6
- * @property {Object} [metadata]
7
- * @property {string} [fullPath]
8
- * @property {string|null} [parentPath]
9
- * @property {RouteConfig|null} [parentRoute]
10
- */
11
-
12
- /**
13
- * @typedef {Object} RouteInfo
14
- * @property {string} path
15
- * @property {string} component
16
- * @property {Object} params
17
- * @property {Object} query
18
- * @property {Object} metadata
19
- */
20
-
21
- /**
22
- * @typedef {Object} GuardRedirect
23
- * @property {string} path
24
- * @property {boolean} [replace]
25
- */
26
-
27
- /**
28
- * @typedef {Object} RouteMatch
29
- * @property {RouteConfig|null} route
30
- * @property {Object} params
31
- * @property {RouteConfig} [childRoute]
32
- */
33
-
34
- /**
35
- * @callback RouterNext
36
- * @param {void|false|string|{ path: string, replace?: boolean }} [arg]
37
- * @returns {void}
38
- */
39
-
40
- export default class Router {
41
- /**
42
- * @param {RouteConfig[]} routes
43
- */
44
- constructor(routes) {
45
- this.routes = routes;
46
- this.activeRoute = null;
47
- this.pathToRouteMap = this.createPathToRouteMap(routes);
48
-
49
- // O(1) case-insensitive lookup for static routes, so a miss on the exact map
50
- // doesn't scan every route on each navigation. Param routes (which carry a
51
- // precompiled `compiled` regex) are excluded — they go through the param loop.
52
- this._staticLowerIndex = new Map();
53
- for (const [pattern, route] of this.pathToRouteMap.entries()) {
54
- if (!route.compiled) this._staticLowerIndex.set(pattern.toLowerCase(), route);
55
- }
56
-
57
- // Navigation Guards
58
- this._beforeEachGuard = null;
59
- this._afterEachGuard = null;
60
-
61
- // Router state
62
- this._started = false;
63
- this._autoStartTimeout = null;
64
-
65
- // Sistema de caché optimizado
66
- this.routeContainersCache = new Map();
67
- this.lastCacheUpdate = 0;
68
- this.CACHE_DURATION = 100; // ms - caché muy corto pero efectivo
69
-
70
- // Observer para invalidar caché automáticamente
71
- this.setupMutationObserver();
72
- }
73
-
74
- /**
75
- * Inicializa el router
76
- * Si el usuario no llama start() manualmente, se auto-inicia despues de un delay
77
- * @returns {void}
78
- */
79
- init() {
80
- window.addEventListener('popstate', () => {
81
- try {
82
- this.onRouteChange();
83
- } catch (error) {
84
- slice.logger.error('Router', 'Error in popstate handler', error);
85
- }
86
- });
87
-
88
- this._autoStartTimeout = setTimeout(async () => {
89
- if (!this._started) {
90
- try {
91
- slice.logger.info('Router', 'Auto-starting router (no manual start() called)');
92
- await this.start();
93
- } catch (error) {
94
- slice.logger.error('Router', 'Auto-start failed', error);
95
- }
96
- }
97
- }, 50);
98
- }
99
-
100
- /**
101
- * Inicia el router y carga la ruta inicial
102
- * OPCIONAL: Solo necesario si usas guards (beforeEach/afterEach)
103
- * Si no lo llamas, el router se auto-inicia despues de 50ms
104
- * @returns {Promise<void>}
105
- */
106
- async start() {
107
- // Prevenir múltiples llamadas
108
- if (this._started) {
109
- slice.logger.logWarning('Router', 'start() already called');
110
- return;
111
- }
112
-
113
- // Cancelar auto-start si existe
114
- if (this._autoStartTimeout) {
115
- clearTimeout(this._autoStartTimeout);
116
- this._autoStartTimeout = null;
117
- }
118
-
119
- this._started = true;
120
- await this.loadInitialRoute();
121
- }
122
-
123
- // ============================================
124
- // NAVIGATION GUARDS API
125
- // ============================================
126
-
127
- /**
128
- * Registra un guard que se ejecuta ANTES de cada navegacion.
129
- * Puede bloquear o redirigir la navegacion mediante next().
130
- * @param {(to: RouteInfo, from: RouteInfo, next: RouterNext) => void|Promise<void>} guard
131
- * @returns {void}
132
- */
133
- beforeEach(guard) {
134
- if (typeof guard !== 'function') {
135
- slice.logger.logError('Router', 'beforeEach expects a function');
136
- return;
137
- }
138
- this._beforeEachGuard = guard;
139
- }
140
-
141
- /**
142
- * Registra un guard que se ejecuta DESPUES de cada navegacion.
143
- * No puede bloquear la navegacion.
144
- * @param {(to: RouteInfo, from: RouteInfo) => void} guard
145
- * @returns {void}
146
- */
147
- afterEach(guard) {
148
- if (typeof guard !== 'function') {
149
- slice.logger.logError('Router', 'afterEach expects a function');
150
- return;
151
- }
152
- this._afterEachGuard = guard;
153
- }
154
-
155
- /**
156
- * Crea un objeto con información de ruta para los guards
157
- * @param {Object} route - Objeto de ruta
158
- * @param {Object} params - Parámetros de la ruta
159
- * @param {String} requestedPath - Path original solicitado
160
- * @returns {Object} Objeto con path, component, params, query, metadata
161
- */
162
- /**
163
- * Build route info used by guards and events.
164
- * @param {RouteConfig|null} route
165
- * @param {Object} [params]
166
- * @param {string|null} [requestedPath]
167
- * @returns {RouteInfo}
168
- */
169
- _createRouteInfo(route, params = {}, requestedPath = null) {
170
- if (!route) {
171
- return {
172
- path: requestedPath || '/404',
173
- component: 'NotFound',
174
- params: {},
175
- query: this._parseQueryParams(),
176
- metadata: {},
177
- };
178
- }
179
-
180
- return {
181
- path: requestedPath || route.fullPath || route.path,
182
- component: route.parentRoute ? route.parentRoute.component : route.component,
183
- params: params,
184
- query: this._parseQueryParams(),
185
- metadata: route.metadata || {},
186
- };
187
- }
188
-
189
- /**
190
- * Parsea los query parameters de la URL actual
191
- * @returns {Object} Objeto con los query params
192
- */
193
- /**
194
- * Parse query params from current URL.
195
- * @returns {Object}
196
- */
197
- _parseQueryParams() {
198
- const queryString = window.location.search;
199
- if (!queryString) return {};
200
-
201
- const params = {};
202
- const urlParams = new URLSearchParams(queryString);
203
-
204
- for (const [key, value] of urlParams) {
205
- params[key] = value;
206
- }
207
-
208
- return params;
209
- }
210
-
211
- /**
212
- * Ejecuta el beforeEach guard si existe
213
- * @param {Object} to - Información de ruta destino
214
- * @param {Object} from - Información de ruta origen
215
- * @returns {Object|null} Objeto con redirectPath y options, o null si continúa
216
- */
217
- /**
218
- * Execute beforeEach guard if defined.
219
- * @param {RouteInfo} to
220
- * @param {RouteInfo} from
221
- * @returns {Promise<{ path: string|false, options: { replace?: boolean } }|null>}
222
- */
223
- async _executeBeforeEachGuard(to, from) {
224
- if (!this._beforeEachGuard) {
225
- return null;
226
- }
227
-
228
- let redirectPath = null;
229
- let redirectOptions = {};
230
- let nextCalled = false;
231
-
232
- const next = (arg) => {
233
- if (nextCalled) {
234
- slice.logger.logWarning('Router', 'next() called multiple times in guard');
235
- return;
236
- }
237
- nextCalled = true;
238
-
239
- // Caso 1: Sin argumentos - continuar navegación
240
- if (arg === undefined) {
241
- return;
242
- }
243
-
244
- // Caso 2: false - cancelar navegación
245
- if (arg === false) {
246
- redirectPath = false;
247
- return;
248
- }
249
-
250
- // Caso 3: String - redirección simple (backward compatibility)
251
- if (typeof arg === 'string') {
252
- redirectPath = arg;
253
- redirectOptions = { replace: false };
254
- return;
255
- }
256
-
257
- // Caso 4: Objeto - redirección con opciones
258
- if (typeof arg === 'object' && arg.path) {
259
- redirectPath = arg.path;
260
- redirectOptions = {
261
- replace: arg.replace || false,
262
- };
263
- return;
264
- }
265
-
266
- // Argumento inválido
267
- slice.logger.logError(
268
- 'Router',
269
- 'Invalid argument passed to next(). Expected string, object with path, false, or undefined.'
270
- );
271
- };
272
-
273
- try {
274
- await this._beforeEachGuard(to, from, next);
275
-
276
- // Si no se llamó next(), loguear advertencia pero continuar
277
- if (!nextCalled) {
278
- slice.logger.logWarning('Router', 'beforeEach guard did not call next(). Navigation will continue.');
279
- }
280
-
281
- // Retornar tanto el path como las opciones
282
- return redirectPath ? { path: redirectPath, options: redirectOptions } : null;
283
- } catch (error) {
284
- slice.logger.error('Router', `Error in beforeEach guard from "${from?.path}" to "${to?.path}"`, error);
285
- return { path: false, options: {} };
286
- }
287
- }
288
-
289
- /**
290
- * Ejecuta el afterEach guard si existe
291
- * @param {Object} to - Información de ruta destino
292
- * @param {Object} from - Información de ruta origen
293
- */
294
- /**
295
- * Execute afterEach guard if defined.
296
- * @param {RouteInfo} to
297
- * @param {RouteInfo} from
298
- * @returns {void}
299
- */
300
- _executeAfterEachGuard(to, from) {
301
- if (!this._afterEachGuard) {
302
- return;
303
- }
304
-
305
- try {
306
- this._afterEachGuard(to, from);
307
- } catch (error) {
308
- slice.logger.logError('Router', 'Error in afterEach guard', error);
309
- }
310
- }
311
-
312
- // ============================================
313
- // ROUTING CORE (MODIFICADO CON GUARDS)
314
- // ============================================
315
-
316
- /**
317
- * Navigate to a route path (guards run automatically).
318
- * @param {string} path
319
- * @param {{ replace?: boolean }} [options] - `{ replace: true }` replaces history instead of pushing.
320
- * @returns {Promise<void>}
321
- */
322
- async navigate(path, options = {}, legacyOptions) {
323
- // Backward compatibility with the previous signature navigate(path, _redirectChain, _options):
324
- // if the 2nd argument is the internal redirect chain (an array) or a 3rd argument is passed,
325
- // use the 3rd argument as the options object.
326
- if (Array.isArray(options)) {
327
- options = legacyOptions || {};
328
- } else if (legacyOptions !== undefined) {
329
- options = legacyOptions || options;
330
- }
331
- return this._navigateWithGuards(path, options || {}, []);
332
- }
333
-
334
- /**
335
- * Internal navigation that tracks the guard redirection chain (loop protection).
336
- * @param {string} path
337
- * @param {{ replace?: boolean }} options
338
- * @param {string[]} redirectChain
339
- * @returns {Promise<void>}
340
- */
341
- async _navigateWithGuards(path, options, redirectChain) {
342
- const currentPath = window.location.pathname;
343
-
344
- // Detectar loops infinitos: si ya visitamos esta ruta en la cadena de redirecciones
345
- if (redirectChain.includes(path)) {
346
- slice.logger.logError('Router', `Guard redirection loop detected: ${redirectChain.join(' → ')} → ${path}`);
347
- return;
348
- }
349
-
350
- // Límite de seguridad: máximo 10 redirecciones
351
- if (redirectChain.length >= 10) {
352
- slice.logger.logError('Router', `Too many redirections: ${redirectChain.join(' → ')} → ${path}`);
353
- return;
354
- }
355
-
356
- // Obtener información de ruta actual
357
- const { route: fromRoute, params: fromParams } = this.matchRoute(currentPath);
358
- const from = this._createRouteInfo(fromRoute, fromParams, currentPath);
359
-
360
- // Obtener información de ruta destino
361
- const { route: toRoute, params: toParams } = this.matchRoute(path);
362
- const to = this._createRouteInfo(toRoute, toParams, path);
363
-
364
- // EJECUTAR BEFORE EACH GUARD
365
- const guardResult = await this._executeBeforeEachGuard(to, from);
366
-
367
- // Si el guard redirige
368
- if (guardResult && guardResult.path) {
369
- return this._navigateWithGuards(guardResult.path, guardResult.options || {}, [...redirectChain, path]);
370
- }
371
-
372
- // Si el guard cancela la navegación (next(false))
373
- if (guardResult && guardResult.path === false) {
374
- slice.logger.logInfo('Router', 'Navigation cancelled by guard');
375
- return;
376
- }
377
-
378
- // No hay redirección - continuar con la navegación normal
379
- // Usar replace o push según las opciones
380
- if (options.replace) {
381
- window.history.replaceState({}, path, window.location.origin + path);
382
- } else {
383
- window.history.pushState({}, path, window.location.origin + path);
384
- }
385
-
386
- await this._performNavigation(to, from);
387
- }
388
-
389
- /**
390
- * Método interno para ejecutar la navegación después de pasar los guards
391
- * @param {Object} to - Información de ruta destino
392
- * @param {Object} from - Información de ruta origen
393
- */
394
- /**
395
- * Perform navigation after guards.
396
- * @param {RouteInfo} to
397
- * @param {RouteInfo} from
398
- * @returns {Promise<void>}
399
- */
400
- async _performNavigation(to, from) {
401
- // Renderizar la nueva ruta
402
- await this.onRouteChange();
403
-
404
- // EJECUTAR AFTER EACH GUARD
405
- this._executeAfterEachGuard(to, from);
406
-
407
- // Emitir evento de cambio de ruta
408
- this._emitRouteChange(to, from);
409
- }
410
-
411
- /**
412
- * React to URL changes and render routes.
413
- * @returns {Promise<void>}
414
- */
415
- async onRouteChange() {
416
- if (this.routeChangeTimeout) {
417
- clearTimeout(this.routeChangeTimeout);
418
- // This call supersedes a still-pending one; resolve the superseded
419
- // promise so anything awaiting it doesn't hang — the new call does the
420
- // real work.
421
- if (this._routeChangeResolve) {
422
- this._routeChangeResolve();
423
- this._routeChangeResolve = null;
424
- }
425
- }
426
-
427
- // Resolve only AFTER the debounced route handling finishes, so a caller
428
- // that awaits this (e.g. _performNavigation) sees slice.router.activeRoute
429
- // already updated before it emits router:change. (Previously the awaited
430
- // promise resolved the instant the timer was scheduled, so router:change
431
- // fired ~10ms before activeRoute was set.)
432
- return new Promise((resolve) => {
433
- this._routeChangeResolve = resolve;
434
- this.routeChangeTimeout = setTimeout(async () => {
435
- try {
436
- const path = window.location.pathname;
437
- const routeContainersFlag = await this.renderRoutesComponentsInPage();
438
-
439
- if (routeContainersFlag) {
440
- return;
441
- }
442
-
443
- const { route, params } = this.matchRoute(path);
444
- if (route) {
445
- await this.handleRoute(route, params);
446
- }
447
- } catch (error) {
448
- slice.logger.error('Router', `Route change failed for ${window.location.pathname}`, error);
449
- } finally {
450
- this._routeChangeResolve = null;
451
- resolve();
452
- }
453
- }, 10);
454
- });
455
- }
456
-
457
- /**
458
- * Build or update the active route component.
459
- * @param {RouteConfig} route
460
- * @param {Object} params
461
- * @returns {Promise<void>}
462
- */
463
- async handleRoute(route, params) {
464
- const targetElement = document.querySelector('#app');
465
-
466
- const componentName = route.parentRoute ? route.parentRoute.component : route.component;
467
- const sliceId = `route-${componentName}`;
468
-
469
- const existingComponent = slice.controller.getComponent(sliceId);
470
-
471
- if (slice.loading) {
472
- slice.loading.start();
473
- }
474
-
475
- try {
476
- if (existingComponent) {
477
- targetElement.innerHTML = '';
478
- if (existingComponent.update) {
479
- existingComponent.props = { ...existingComponent.props, ...params };
480
- await existingComponent.update();
481
- }
482
- targetElement.appendChild(existingComponent);
483
- await this.renderRoutesInComponent(existingComponent);
484
- } else {
485
- const component = await slice.build(componentName, {
486
- params,
487
- sliceId: sliceId,
488
- });
489
-
490
- targetElement.innerHTML = '';
491
- targetElement.appendChild(component);
492
-
493
- await this.renderRoutesInComponent(component);
494
- }
495
-
496
- // Invalidar caché después de cambios importantes en el DOM
497
- this.invalidateCache();
498
- slice.router.activeRoute = route;
499
- } finally {
500
- if (slice.loading) {
501
- slice.loading.stop();
502
- }
503
- }
504
- }
505
-
506
- /**
507
- * Load initial route and run guards.
508
- * @returns {Promise<void>}
509
- */
510
- async loadInitialRoute() {
511
- const path = window.location.pathname;
512
- const { route, params } = this.matchRoute(path);
513
-
514
- // Para la carga inicial, también ejecutar guards
515
- const from = this._createRouteInfo(null, {}, null);
516
- const to = this._createRouteInfo(route, params, path);
517
-
518
- // EJECUTAR BEFORE EACH GUARD en carga inicial
519
- const guardResult = await this._executeBeforeEachGuard(to, from);
520
-
521
- if (guardResult && guardResult.path) {
522
- return this.navigate(guardResult.path, guardResult.options || {});
523
- }
524
-
525
- // Si el guard cancela la navegación inicial (caso raro pero posible)
526
- if (guardResult && guardResult.path === false) {
527
- slice.logger.logWarning('Router', 'Initial route navigation cancelled by guard');
528
- return;
529
- }
530
-
531
- await this.handleRoute(route, params);
532
-
533
- // EJECUTAR AFTER EACH GUARD en carga inicial
534
- this._executeAfterEachGuard(to, from);
535
-
536
- // Emitir evento de cambio de ruta
537
- this._emitRouteChange(to, from);
538
- }
539
-
540
- /**
541
- * Emitir evento de cambio de ruta
542
- * @param {Object} to
543
- * @param {Object} from
544
- */
545
- /**
546
- * Emit route change event.
547
- * @param {RouteInfo} to
548
- * @param {RouteInfo} from
549
- * @returns {void}
550
- */
551
- _emitRouteChange(to, from) {
552
- const payload = { to, from };
553
-
554
- if (slice.eventsConfig?.enabled && slice.events && typeof slice.events.emit === 'function') {
555
- slice.events.emit('router:change', payload);
556
- return;
557
- }
558
-
559
- window.dispatchEvent(new CustomEvent('router:change', { detail: payload }));
560
- }
561
-
562
- // ============================================
563
- // MÉTODOS EXISTENTES (SIN CAMBIOS)
564
- // ============================================
565
-
566
- setupMutationObserver() {
567
- if (typeof MutationObserver !== 'undefined') {
568
- this.observer = new MutationObserver((mutations) => {
569
- try {
570
- let shouldInvalidateCache = false;
571
-
572
- mutations.forEach((mutation) => {
573
- if (mutation.type === 'childList') {
574
- const addedNodes = Array.from(mutation.addedNodes || []);
575
- const removedNodes = Array.from(mutation.removedNodes || []);
576
-
577
- const hasRouteNodes = [...addedNodes, ...removedNodes].some(
578
- (node) =>
579
- node.nodeType === Node.ELEMENT_NODE &&
580
- (node.tagName === 'SLICE-ROUTE' ||
581
- node.tagName === 'SLICE-MULTI-ROUTE' ||
582
- node.querySelector?.('slice-route, slice-multi-route'))
583
- );
584
-
585
- if (hasRouteNodes) {
586
- shouldInvalidateCache = true;
587
- }
588
- }
589
- });
590
-
591
- if (shouldInvalidateCache) {
592
- this.invalidateCache();
593
- }
594
- } catch (error) {
595
- slice.logger.error('Router', 'Error in MutationObserver callback', error);
596
- }
597
- });
598
-
599
- this.observer.observe(document.body, {
600
- childList: true,
601
- subtree: true,
602
- });
603
- }
604
- }
605
-
606
- invalidateCache() {
607
- this.routeContainersCache.clear();
608
- this.lastCacheUpdate = 0;
609
- }
610
-
611
- createPathToRouteMap(routes, basePath = '', parentRoute = null) {
612
- const pathToRouteMap = new Map();
613
-
614
- for (const route of routes) {
615
- const fullPath = `${basePath}${route.path}`.replace(/\/+/g, '/');
616
-
617
- const routeWithParent = {
618
- ...route,
619
- fullPath,
620
- parentPath: parentRoute ? parentRoute.fullPath : null,
621
- parentRoute: parentRoute,
622
- };
623
-
624
- // Compile parameterized patterns once at map-build time instead of on every
625
- // navigation. Static routes leave `compiled` undefined.
626
- if (fullPath.includes('${')) {
627
- routeWithParent.compiled = this.compilePathPattern(fullPath);
628
- }
629
-
630
- pathToRouteMap.set(fullPath, routeWithParent);
631
-
632
- if (route.children) {
633
- const childPathToRouteMap = this.createPathToRouteMap(route.children, fullPath, routeWithParent);
634
-
635
- for (const [childPath, childRoute] of childPathToRouteMap.entries()) {
636
- pathToRouteMap.set(childPath, childRoute);
637
- }
638
- }
639
- }
640
-
641
- return pathToRouteMap;
642
- }
643
-
644
- /**
645
- * Render any Route/MultiRoute components in a container.
646
- * @param {Document|HTMLElement} [searchContainer]
647
- * @returns {Promise<boolean>}
648
- */
649
- async renderRoutesComponentsInPage(searchContainer = document) {
650
- let routerContainersFlag = false;
651
- const routeContainers = this.getCachedRouteContainers(searchContainer);
652
-
653
- for (const routeContainer of routeContainers) {
654
- try {
655
- if (!routeContainer.isConnected) {
656
- this.invalidateCache();
657
- continue;
658
- }
659
-
660
- let response = await routeContainer.renderIfCurrentRoute();
661
- if (response) {
662
- this.activeRoute = routeContainer.props;
663
- routerContainersFlag = true;
664
- }
665
- } catch (error) {
666
- const containerName = routeContainer?.tagName || routeContainer?.constructor?.name || 'unknown';
667
- slice.logger.error('Router', `Error rendering route container "${containerName}"`, error);
668
- }
669
- }
670
-
671
- return routerContainersFlag;
672
- }
673
-
674
- getCachedRouteContainers(container) {
675
- const containerKey = container === document ? 'document' : container.sliceId || 'anonymous';
676
- const now = Date.now();
677
-
678
- if (this.routeContainersCache.has(containerKey) && now - this.lastCacheUpdate < this.CACHE_DURATION) {
679
- return this.routeContainersCache.get(containerKey);
680
- }
681
-
682
- const routeContainers = this.findAllRouteContainersOptimized(container);
683
- this.routeContainersCache.set(containerKey, routeContainers);
684
- this.lastCacheUpdate = now;
685
-
686
- return routeContainers;
687
- }
688
-
689
- findAllRouteContainersOptimized(container) {
690
- const routeContainers = [];
691
-
692
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
693
- acceptNode: (node) => {
694
- if (node.tagName === 'SLICE-ROUTE' || node.tagName === 'SLICE-MULTI-ROUTE') {
695
- return NodeFilter.FILTER_ACCEPT;
696
- }
697
- return NodeFilter.FILTER_SKIP;
698
- },
699
- });
700
-
701
- let node;
702
- while ((node = walker.nextNode())) {
703
- routeContainers.push(node);
704
- }
705
-
706
- return routeContainers;
707
- }
708
-
709
- /**
710
- * Render route containers inside a component.
711
- * @param {HTMLElement} component
712
- * @returns {Promise<boolean>}
713
- */
714
- async renderRoutesInComponent(component) {
715
- if (!component) {
716
- slice.logger.logWarning('Router', 'No component provided for route rendering');
717
- return false;
718
- }
719
-
720
- return await this.renderRoutesComponentsInPage(component);
721
- }
722
-
723
- /**
724
- * Match a path to a configured route.
725
- * @param {string} path
726
- * @returns {RouteMatch}
727
- */
728
- matchRoute(path) {
729
- // Normalize a trailing slash ('/about/' -> '/about'); keep root '/' as-is.
730
- path = path.length > 1 ? path.replace(/\/+$/, '') : path;
731
- // Exact match first (fast path), then a case-insensitive match on static paths
732
- // so '/About' resolves to a route declared as '/about'.
733
- let exactMatch = this.pathToRouteMap.get(path);
734
- if (!exactMatch) {
735
- exactMatch = this._staticLowerIndex.get(path.toLowerCase());
736
- }
737
- if (exactMatch) {
738
- if (exactMatch.parentRoute) {
739
- return {
740
- route: exactMatch.parentRoute,
741
- params: {},
742
- childRoute: exactMatch,
743
- };
744
- }
745
- return { route: exactMatch, params: {} };
746
- }
747
-
748
- for (const route of this.pathToRouteMap.values()) {
749
- if (route.compiled) {
750
- const { regex, paramNames } = route.compiled;
751
- const match = path.match(regex);
752
- if (match) {
753
- const params = {};
754
- paramNames.forEach((name, i) => {
755
- params[name] = match[i + 1];
756
- });
757
-
758
- if (route.parentRoute) {
759
- return {
760
- route: route.parentRoute,
761
- params: params,
762
- childRoute: route,
763
- };
764
- }
765
-
766
- return { route, params };
767
- }
768
- }
769
- }
770
-
771
- const notFoundRoute = this.pathToRouteMap.get('/404');
772
- return { route: notFoundRoute, params: {} };
773
- }
774
-
775
- /**
776
- * Compile a path pattern with ${param} segments.
777
- * @param {string} pattern
778
- * @returns {{ regex: RegExp, paramNames: string[] }}
779
- */
780
- compilePathPattern(pattern) {
781
- const paramNames = [];
782
- const regexPattern =
783
- '^' +
784
- pattern.replace(/\$\{([^}]+)\}/g, (_, paramName) => {
785
- paramNames.push(paramName);
786
- return '([^/]+)';
787
- }) +
788
- '$';
789
-
790
- // 'i' flag: paths match case-insensitively. Captured param values keep their original case.
791
- return { regex: new RegExp(regexPattern, 'i'), paramNames };
792
- }
793
- }
1
+ /**
2
+ * @typedef {Object} RouteConfig
3
+ * @property {string} path
4
+ * @property {string} component
5
+ * @property {RouteConfig[]} [children]
6
+ * @property {Object} [metadata]
7
+ * @property {string} [fullPath]
8
+ * @property {string|null} [parentPath]
9
+ * @property {RouteConfig|null} [parentRoute]
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} RouteInfo
14
+ * @property {string} path
15
+ * @property {string} component
16
+ * @property {Object} params
17
+ * @property {Object} query
18
+ * @property {Object} metadata
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} GuardRedirect
23
+ * @property {string} path
24
+ * @property {boolean} [replace]
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} RouteMatch
29
+ * @property {RouteConfig|null} route
30
+ * @property {Object} params
31
+ * @property {RouteConfig} [childRoute]
32
+ */
33
+
34
+ /**
35
+ * @callback RouterNext
36
+ * @param {void|false|string|{ path: string, replace?: boolean }} [arg]
37
+ * @returns {void}
38
+ */
39
+
40
+ export default class Router {
41
+ /**
42
+ * @param {RouteConfig[]} routes
43
+ */
44
+ constructor(routes) {
45
+ this.routes = routes;
46
+ this.activeRoute = null;
47
+ this.pathToRouteMap = this.createPathToRouteMap(routes);
48
+
49
+ // O(1) case-insensitive lookup for static routes, so a miss on the exact map
50
+ // doesn't scan every route on each navigation. Param routes (which carry a
51
+ // precompiled `compiled` regex) are excluded — they go through the param loop.
52
+ this._staticLowerIndex = new Map();
53
+ for (const [pattern, route] of this.pathToRouteMap.entries()) {
54
+ if (!route.compiled) this._staticLowerIndex.set(pattern.toLowerCase(), route);
55
+ }
56
+
57
+ // Navigation Guards
58
+ this._beforeEachGuard = null;
59
+ this._afterEachGuard = null;
60
+
61
+ // Router state
62
+ this._started = false;
63
+ this._autoStartTimeout = null;
64
+
65
+ // Sistema de caché optimizado
66
+ this.routeContainersCache = new Map();
67
+ this.lastCacheUpdate = 0;
68
+ this.CACHE_DURATION = 100; // ms - caché muy corto pero efectivo
69
+
70
+ // Observer para invalidar caché automáticamente
71
+ this.setupMutationObserver();
72
+ }
73
+
74
+ /**
75
+ * Inicializa el router
76
+ * Si el usuario no llama start() manualmente, se auto-inicia despues de un delay
77
+ * @returns {void}
78
+ */
79
+ init() {
80
+ window.addEventListener('popstate', () => {
81
+ try {
82
+ this.onRouteChange();
83
+ } catch (error) {
84
+ slice.logger.error('Router', 'Error in popstate handler', error);
85
+ }
86
+ });
87
+
88
+ this._autoStartTimeout = setTimeout(async () => {
89
+ if (!this._started) {
90
+ try {
91
+ slice.logger.info('Router', 'Auto-starting router (no manual start() called)');
92
+ await this.start();
93
+ } catch (error) {
94
+ slice.logger.error('Router', 'Auto-start failed', error);
95
+ }
96
+ }
97
+ }, 50);
98
+ }
99
+
100
+ /**
101
+ * Inicia el router y carga la ruta inicial
102
+ * OPCIONAL: Solo necesario si usas guards (beforeEach/afterEach)
103
+ * Si no lo llamas, el router se auto-inicia despues de 50ms
104
+ * @returns {Promise<void>}
105
+ */
106
+ async start() {
107
+ // Prevenir múltiples llamadas
108
+ if (this._started) {
109
+ slice.logger.logWarning('Router', 'start() already called');
110
+ return;
111
+ }
112
+
113
+ // Cancelar auto-start si existe
114
+ if (this._autoStartTimeout) {
115
+ clearTimeout(this._autoStartTimeout);
116
+ this._autoStartTimeout = null;
117
+ }
118
+
119
+ this._started = true;
120
+ await this.loadInitialRoute();
121
+ }
122
+
123
+ // ============================================
124
+ // NAVIGATION GUARDS API
125
+ // ============================================
126
+
127
+ /**
128
+ * Registra un guard que se ejecuta ANTES de cada navegacion.
129
+ * Puede bloquear o redirigir la navegacion mediante next().
130
+ * @param {(to: RouteInfo, from: RouteInfo, next: RouterNext) => void|Promise<void>} guard
131
+ * @returns {void}
132
+ */
133
+ beforeEach(guard) {
134
+ if (typeof guard !== 'function') {
135
+ slice.logger.logError('Router', 'beforeEach expects a function');
136
+ return;
137
+ }
138
+ this._beforeEachGuard = guard;
139
+ }
140
+
141
+ /**
142
+ * Registra un guard que se ejecuta DESPUES de cada navegacion.
143
+ * No puede bloquear la navegacion.
144
+ * @param {(to: RouteInfo, from: RouteInfo) => void} guard
145
+ * @returns {void}
146
+ */
147
+ afterEach(guard) {
148
+ if (typeof guard !== 'function') {
149
+ slice.logger.logError('Router', 'afterEach expects a function');
150
+ return;
151
+ }
152
+ this._afterEachGuard = guard;
153
+ }
154
+
155
+ /**
156
+ * Crea un objeto con información de ruta para los guards
157
+ * @param {Object} route - Objeto de ruta
158
+ * @param {Object} params - Parámetros de la ruta
159
+ * @param {String} requestedPath - Path original solicitado
160
+ * @returns {Object} Objeto con path, component, params, query, metadata
161
+ */
162
+ /**
163
+ * Build route info used by guards and events.
164
+ * @param {RouteConfig|null} route
165
+ * @param {Object} [params]
166
+ * @param {string|null} [requestedPath]
167
+ * @returns {RouteInfo}
168
+ */
169
+ _createRouteInfo(route, params = {}, requestedPath = null) {
170
+ if (!route) {
171
+ return {
172
+ path: requestedPath || '/404',
173
+ component: 'NotFound',
174
+ params: {},
175
+ query: this._parseQueryParams(),
176
+ metadata: {},
177
+ };
178
+ }
179
+
180
+ return {
181
+ path: requestedPath || route.fullPath || route.path,
182
+ component: route.parentRoute ? route.parentRoute.component : route.component,
183
+ params: params,
184
+ query: this._parseQueryParams(),
185
+ metadata: route.metadata || {},
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Parsea los query parameters de la URL actual
191
+ * @returns {Object} Objeto con los query params
192
+ */
193
+ /**
194
+ * Parse query params from current URL.
195
+ * @returns {Object}
196
+ */
197
+ _parseQueryParams() {
198
+ const queryString = window.location.search;
199
+ if (!queryString) return {};
200
+
201
+ const params = {};
202
+ const urlParams = new URLSearchParams(queryString);
203
+
204
+ for (const [key, value] of urlParams) {
205
+ params[key] = value;
206
+ }
207
+
208
+ return params;
209
+ }
210
+
211
+ /**
212
+ * Ejecuta el beforeEach guard si existe
213
+ * @param {Object} to - Información de ruta destino
214
+ * @param {Object} from - Información de ruta origen
215
+ * @returns {Object|null} Objeto con redirectPath y options, o null si continúa
216
+ */
217
+ /**
218
+ * Execute beforeEach guard if defined.
219
+ * @param {RouteInfo} to
220
+ * @param {RouteInfo} from
221
+ * @returns {Promise<{ path: string|false, options: { replace?: boolean } }|null>}
222
+ */
223
+ async _executeBeforeEachGuard(to, from) {
224
+ if (!this._beforeEachGuard) {
225
+ return null;
226
+ }
227
+
228
+ let redirectPath = null;
229
+ let redirectOptions = {};
230
+ let nextCalled = false;
231
+
232
+ const next = (arg) => {
233
+ if (nextCalled) {
234
+ slice.logger.logWarning('Router', 'next() called multiple times in guard');
235
+ return;
236
+ }
237
+ nextCalled = true;
238
+
239
+ // Caso 1: Sin argumentos - continuar navegación
240
+ if (arg === undefined) {
241
+ return;
242
+ }
243
+
244
+ // Caso 2: false - cancelar navegación
245
+ if (arg === false) {
246
+ redirectPath = false;
247
+ return;
248
+ }
249
+
250
+ // Caso 3: String - redirección simple (backward compatibility)
251
+ if (typeof arg === 'string') {
252
+ redirectPath = arg;
253
+ redirectOptions = { replace: false };
254
+ return;
255
+ }
256
+
257
+ // Caso 4: Objeto - redirección con opciones
258
+ if (typeof arg === 'object' && arg.path) {
259
+ redirectPath = arg.path;
260
+ redirectOptions = {
261
+ replace: arg.replace || false,
262
+ };
263
+ return;
264
+ }
265
+
266
+ // Argumento inválido
267
+ slice.logger.logError(
268
+ 'Router',
269
+ 'Invalid argument passed to next(). Expected string, object with path, false, or undefined.'
270
+ );
271
+ };
272
+
273
+ try {
274
+ await this._beforeEachGuard(to, from, next);
275
+
276
+ // Si no se llamó next(), loguear advertencia pero continuar
277
+ if (!nextCalled) {
278
+ slice.logger.logWarning('Router', 'beforeEach guard did not call next(). Navigation will continue.');
279
+ }
280
+
281
+ // Retornar tanto el path como las opciones
282
+ return redirectPath ? { path: redirectPath, options: redirectOptions } : null;
283
+ } catch (error) {
284
+ slice.logger.error('Router', `Error in beforeEach guard from "${from?.path}" to "${to?.path}"`, error);
285
+ return { path: false, options: {} };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Ejecuta el afterEach guard si existe
291
+ * @param {Object} to - Información de ruta destino
292
+ * @param {Object} from - Información de ruta origen
293
+ */
294
+ /**
295
+ * Execute afterEach guard if defined.
296
+ * @param {RouteInfo} to
297
+ * @param {RouteInfo} from
298
+ * @returns {void}
299
+ */
300
+ _executeAfterEachGuard(to, from) {
301
+ if (!this._afterEachGuard) {
302
+ return;
303
+ }
304
+
305
+ try {
306
+ this._afterEachGuard(to, from);
307
+ } catch (error) {
308
+ slice.logger.logError('Router', 'Error in afterEach guard', error);
309
+ }
310
+ }
311
+
312
+ // ============================================
313
+ // ROUTING CORE (MODIFICADO CON GUARDS)
314
+ // ============================================
315
+
316
+ /**
317
+ * Navigate to a route path (guards run automatically).
318
+ * @param {string} path
319
+ * @param {{ replace?: boolean }} [options] - `{ replace: true }` replaces history instead of pushing.
320
+ * @returns {Promise<void>}
321
+ */
322
+ async navigate(path, options = {}, legacyOptions) {
323
+ // Backward compatibility with the previous signature navigate(path, _redirectChain, _options):
324
+ // if the 2nd argument is the internal redirect chain (an array) or a 3rd argument is passed,
325
+ // use the 3rd argument as the options object.
326
+ if (Array.isArray(options)) {
327
+ options = legacyOptions || {};
328
+ } else if (legacyOptions !== undefined) {
329
+ options = legacyOptions || options;
330
+ }
331
+ return this._navigateWithGuards(path, options || {}, []);
332
+ }
333
+
334
+ /**
335
+ * Internal navigation that tracks the guard redirection chain (loop protection).
336
+ * @param {string} path
337
+ * @param {{ replace?: boolean }} options
338
+ * @param {string[]} redirectChain
339
+ * @returns {Promise<void>}
340
+ */
341
+ async _navigateWithGuards(path, options, redirectChain) {
342
+ const currentPath = window.location.pathname;
343
+
344
+ // Detectar loops infinitos: si ya visitamos esta ruta en la cadena de redirecciones
345
+ if (redirectChain.includes(path)) {
346
+ slice.logger.logError('Router', `Guard redirection loop detected: ${redirectChain.join(' → ')} → ${path}`);
347
+ return;
348
+ }
349
+
350
+ // Límite de seguridad: máximo 10 redirecciones
351
+ if (redirectChain.length >= 10) {
352
+ slice.logger.logError('Router', `Too many redirections: ${redirectChain.join(' → ')} → ${path}`);
353
+ return;
354
+ }
355
+
356
+ // Obtener información de ruta actual
357
+ const { route: fromRoute, params: fromParams } = this.matchRoute(currentPath);
358
+ const from = this._createRouteInfo(fromRoute, fromParams, currentPath);
359
+
360
+ // Obtener información de ruta destino
361
+ const { route: toRoute, params: toParams } = this.matchRoute(path);
362
+ const to = this._createRouteInfo(toRoute, toParams, path);
363
+
364
+ // EJECUTAR BEFORE EACH GUARD
365
+ const guardResult = await this._executeBeforeEachGuard(to, from);
366
+
367
+ // Si el guard redirige
368
+ if (guardResult && guardResult.path) {
369
+ return this._navigateWithGuards(guardResult.path, guardResult.options || {}, [...redirectChain, path]);
370
+ }
371
+
372
+ // Si el guard cancela la navegación (next(false))
373
+ if (guardResult && guardResult.path === false) {
374
+ slice.logger.logInfo('Router', 'Navigation cancelled by guard');
375
+ return;
376
+ }
377
+
378
+ // No hay redirección - continuar con la navegación normal
379
+ // Usar replace o push según las opciones
380
+ if (options.replace) {
381
+ window.history.replaceState({}, path, window.location.origin + path);
382
+ } else {
383
+ window.history.pushState({}, path, window.location.origin + path);
384
+ }
385
+
386
+ await this._performNavigation(to, from);
387
+ }
388
+
389
+ /**
390
+ * Método interno para ejecutar la navegación después de pasar los guards
391
+ * @param {Object} to - Información de ruta destino
392
+ * @param {Object} from - Información de ruta origen
393
+ */
394
+ /**
395
+ * Perform navigation after guards.
396
+ * @param {RouteInfo} to
397
+ * @param {RouteInfo} from
398
+ * @returns {Promise<void>}
399
+ */
400
+ async _performNavigation(to, from) {
401
+ // Renderizar la nueva ruta
402
+ await this.onRouteChange();
403
+
404
+ // EJECUTAR AFTER EACH GUARD
405
+ this._executeAfterEachGuard(to, from);
406
+
407
+ // Emitir evento de cambio de ruta
408
+ this._emitRouteChange(to, from);
409
+ }
410
+
411
+ /**
412
+ * React to URL changes and render routes.
413
+ * @returns {Promise<void>}
414
+ */
415
+ async onRouteChange() {
416
+ if (this.routeChangeTimeout) {
417
+ clearTimeout(this.routeChangeTimeout);
418
+ // This call supersedes a still-pending one; resolve the superseded
419
+ // promise so anything awaiting it doesn't hang — the new call does the
420
+ // real work.
421
+ if (this._routeChangeResolve) {
422
+ this._routeChangeResolve();
423
+ this._routeChangeResolve = null;
424
+ }
425
+ }
426
+
427
+ // Resolve only AFTER the debounced route handling finishes, so a caller
428
+ // that awaits this (e.g. _performNavigation) sees slice.router.activeRoute
429
+ // already updated before it emits router:change. (Previously the awaited
430
+ // promise resolved the instant the timer was scheduled, so router:change
431
+ // fired ~10ms before activeRoute was set.)
432
+ return new Promise((resolve) => {
433
+ this._routeChangeResolve = resolve;
434
+ this.routeChangeTimeout = setTimeout(async () => {
435
+ try {
436
+ const path = window.location.pathname;
437
+ const routeContainersFlag = await this.renderRoutesComponentsInPage();
438
+
439
+ if (routeContainersFlag) {
440
+ return;
441
+ }
442
+
443
+ const { route, params } = this.matchRoute(path);
444
+ if (route) {
445
+ await this.handleRoute(route, params);
446
+ }
447
+ } catch (error) {
448
+ slice.logger.error('Router', `Route change failed for ${window.location.pathname}`, error);
449
+ } finally {
450
+ this._routeChangeResolve = null;
451
+ resolve();
452
+ }
453
+ }, 10);
454
+ });
455
+ }
456
+
457
+ /**
458
+ * Build or update the active route component.
459
+ * @param {RouteConfig} route
460
+ * @param {Object} params
461
+ * @returns {Promise<void>}
462
+ */
463
+ async handleRoute(route, params) {
464
+ const targetElement = document.querySelector('#app');
465
+
466
+ const componentName = route.parentRoute ? route.parentRoute.component : route.component;
467
+ const sliceId = `route-${componentName}`;
468
+
469
+ if (!targetElement) {
470
+ slice.logger.logError('Router', `Cannot render route "${route?.path ?? componentName}": no #app element in the document`);
471
+ return;
472
+ }
473
+
474
+ const existingComponent = slice.controller.getComponent(sliceId);
475
+
476
+ if (slice.loading) {
477
+ slice.loading.start();
478
+ }
479
+
480
+ try {
481
+ if (existingComponent) {
482
+ targetElement.innerHTML = '';
483
+ if (existingComponent.update) {
484
+ existingComponent.props = { ...existingComponent.props, ...params };
485
+ await existingComponent.update();
486
+ }
487
+ targetElement.appendChild(existingComponent);
488
+ await this.renderRoutesInComponent(existingComponent);
489
+ } else {
490
+ const component = await slice.build(componentName, {
491
+ params,
492
+ sliceId: sliceId,
493
+ });
494
+
495
+ // build() returns null whenever the component could not be produced
496
+ // (not registered, a resource fetch failed or was aborted, init()
497
+ // threw). Appending null raises "parameter 1 is not of type 'Node'",
498
+ // which buries the real cause. Report it and keep the current view.
499
+ if (!component) {
500
+ slice.logger.logError(
501
+ 'Router',
502
+ `Cannot render route "${route?.path ?? componentName}": slice.build('${componentName}') returned null`
503
+ );
504
+ return;
505
+ }
506
+
507
+ targetElement.innerHTML = '';
508
+ targetElement.appendChild(component);
509
+
510
+ await this.renderRoutesInComponent(component);
511
+ }
512
+
513
+ // Invalidar caché después de cambios importantes en el DOM
514
+ this.invalidateCache();
515
+ slice.router.activeRoute = route;
516
+ } finally {
517
+ if (slice.loading) {
518
+ slice.loading.stop();
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Load initial route and run guards.
525
+ * @returns {Promise<void>}
526
+ */
527
+ async loadInitialRoute() {
528
+ const path = window.location.pathname;
529
+ const { route, params } = this.matchRoute(path);
530
+
531
+ // Para la carga inicial, también ejecutar guards
532
+ const from = this._createRouteInfo(null, {}, null);
533
+ const to = this._createRouteInfo(route, params, path);
534
+
535
+ // EJECUTAR BEFORE EACH GUARD en carga inicial
536
+ const guardResult = await this._executeBeforeEachGuard(to, from);
537
+
538
+ if (guardResult && guardResult.path) {
539
+ return this.navigate(guardResult.path, guardResult.options || {});
540
+ }
541
+
542
+ // Si el guard cancela la navegación inicial (caso raro pero posible)
543
+ if (guardResult && guardResult.path === false) {
544
+ slice.logger.logWarning('Router', 'Initial route navigation cancelled by guard');
545
+ return;
546
+ }
547
+
548
+ await this.handleRoute(route, params);
549
+
550
+ // EJECUTAR AFTER EACH GUARD en carga inicial
551
+ this._executeAfterEachGuard(to, from);
552
+
553
+ // Emitir evento de cambio de ruta
554
+ this._emitRouteChange(to, from);
555
+ }
556
+
557
+ /**
558
+ * Emitir evento de cambio de ruta
559
+ * @param {Object} to
560
+ * @param {Object} from
561
+ */
562
+ /**
563
+ * Emit route change event.
564
+ * @param {RouteInfo} to
565
+ * @param {RouteInfo} from
566
+ * @returns {void}
567
+ */
568
+ _emitRouteChange(to, from) {
569
+ const payload = { to, from };
570
+
571
+ if (slice.eventsConfig?.enabled && slice.events && typeof slice.events.emit === 'function') {
572
+ slice.events.emit('router:change', payload);
573
+ return;
574
+ }
575
+
576
+ window.dispatchEvent(new CustomEvent('router:change', { detail: payload }));
577
+ }
578
+
579
+ // ============================================
580
+ // MÉTODOS EXISTENTES (SIN CAMBIOS)
581
+ // ============================================
582
+
583
+ setupMutationObserver() {
584
+ if (typeof MutationObserver !== 'undefined') {
585
+ this.observer = new MutationObserver((mutations) => {
586
+ try {
587
+ let shouldInvalidateCache = false;
588
+
589
+ mutations.forEach((mutation) => {
590
+ if (mutation.type === 'childList') {
591
+ const addedNodes = Array.from(mutation.addedNodes || []);
592
+ const removedNodes = Array.from(mutation.removedNodes || []);
593
+
594
+ const hasRouteNodes = [...addedNodes, ...removedNodes].some(
595
+ (node) =>
596
+ node.nodeType === Node.ELEMENT_NODE &&
597
+ (node.tagName === 'SLICE-ROUTE' ||
598
+ node.tagName === 'SLICE-MULTI-ROUTE' ||
599
+ node.querySelector?.('slice-route, slice-multi-route'))
600
+ );
601
+
602
+ if (hasRouteNodes) {
603
+ shouldInvalidateCache = true;
604
+ }
605
+ }
606
+ });
607
+
608
+ if (shouldInvalidateCache) {
609
+ this.invalidateCache();
610
+ }
611
+ } catch (error) {
612
+ slice.logger.error('Router', 'Error in MutationObserver callback', error);
613
+ }
614
+ });
615
+
616
+ this.observer.observe(document.body, {
617
+ childList: true,
618
+ subtree: true,
619
+ });
620
+ }
621
+ }
622
+
623
+ invalidateCache() {
624
+ this.routeContainersCache.clear();
625
+ this.lastCacheUpdate = 0;
626
+ }
627
+
628
+ createPathToRouteMap(routes, basePath = '', parentRoute = null) {
629
+ const pathToRouteMap = new Map();
630
+
631
+ for (const route of routes) {
632
+ const fullPath = `${basePath}${route.path}`.replace(/\/+/g, '/');
633
+
634
+ const routeWithParent = {
635
+ ...route,
636
+ fullPath,
637
+ parentPath: parentRoute ? parentRoute.fullPath : null,
638
+ parentRoute: parentRoute,
639
+ };
640
+
641
+ // Compile parameterized patterns once at map-build time instead of on every
642
+ // navigation. Static routes leave `compiled` undefined.
643
+ if (fullPath.includes('${')) {
644
+ routeWithParent.compiled = this.compilePathPattern(fullPath);
645
+ }
646
+
647
+ pathToRouteMap.set(fullPath, routeWithParent);
648
+
649
+ if (route.children) {
650
+ const childPathToRouteMap = this.createPathToRouteMap(route.children, fullPath, routeWithParent);
651
+
652
+ for (const [childPath, childRoute] of childPathToRouteMap.entries()) {
653
+ pathToRouteMap.set(childPath, childRoute);
654
+ }
655
+ }
656
+ }
657
+
658
+ return pathToRouteMap;
659
+ }
660
+
661
+ /**
662
+ * Render any Route/MultiRoute components in a container.
663
+ * @param {Document|HTMLElement} [searchContainer]
664
+ * @returns {Promise<boolean>}
665
+ */
666
+ async renderRoutesComponentsInPage(searchContainer = document) {
667
+ let routerContainersFlag = false;
668
+ const routeContainers = this.getCachedRouteContainers(searchContainer);
669
+
670
+ for (const routeContainer of routeContainers) {
671
+ try {
672
+ if (!routeContainer.isConnected) {
673
+ this.invalidateCache();
674
+ continue;
675
+ }
676
+
677
+ let response = await routeContainer.renderIfCurrentRoute();
678
+ if (response) {
679
+ this.activeRoute = routeContainer.props;
680
+ routerContainersFlag = true;
681
+ }
682
+ } catch (error) {
683
+ const containerName = routeContainer?.tagName || routeContainer?.constructor?.name || 'unknown';
684
+ slice.logger.error('Router', `Error rendering route container "${containerName}"`, error);
685
+ }
686
+ }
687
+
688
+ return routerContainersFlag;
689
+ }
690
+
691
+ getCachedRouteContainers(container) {
692
+ const containerKey = container === document ? 'document' : container.sliceId || 'anonymous';
693
+ const now = Date.now();
694
+
695
+ if (this.routeContainersCache.has(containerKey) && now - this.lastCacheUpdate < this.CACHE_DURATION) {
696
+ return this.routeContainersCache.get(containerKey);
697
+ }
698
+
699
+ const routeContainers = this.findAllRouteContainersOptimized(container);
700
+ this.routeContainersCache.set(containerKey, routeContainers);
701
+ this.lastCacheUpdate = now;
702
+
703
+ return routeContainers;
704
+ }
705
+
706
+ findAllRouteContainersOptimized(container) {
707
+ const routeContainers = [];
708
+
709
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
710
+ acceptNode: (node) => {
711
+ if (node.tagName === 'SLICE-ROUTE' || node.tagName === 'SLICE-MULTI-ROUTE') {
712
+ return NodeFilter.FILTER_ACCEPT;
713
+ }
714
+ return NodeFilter.FILTER_SKIP;
715
+ },
716
+ });
717
+
718
+ let node;
719
+ while ((node = walker.nextNode())) {
720
+ routeContainers.push(node);
721
+ }
722
+
723
+ return routeContainers;
724
+ }
725
+
726
+ /**
727
+ * Render route containers inside a component.
728
+ * @param {HTMLElement} component
729
+ * @returns {Promise<boolean>}
730
+ */
731
+ async renderRoutesInComponent(component) {
732
+ if (!component) {
733
+ slice.logger.logWarning('Router', 'No component provided for route rendering');
734
+ return false;
735
+ }
736
+
737
+ return await this.renderRoutesComponentsInPage(component);
738
+ }
739
+
740
+ /**
741
+ * Match a path to a configured route.
742
+ * @param {string} path
743
+ * @returns {RouteMatch}
744
+ */
745
+ matchRoute(path) {
746
+ // Normalize a trailing slash ('/about/' -> '/about'); keep root '/' as-is.
747
+ path = path.length > 1 ? path.replace(/\/+$/, '') : path;
748
+ // Exact match first (fast path), then a case-insensitive match on static paths
749
+ // so '/About' resolves to a route declared as '/about'.
750
+ let exactMatch = this.pathToRouteMap.get(path);
751
+ if (!exactMatch) {
752
+ exactMatch = this._staticLowerIndex.get(path.toLowerCase());
753
+ }
754
+ if (exactMatch) {
755
+ if (exactMatch.parentRoute) {
756
+ return {
757
+ route: exactMatch.parentRoute,
758
+ params: {},
759
+ childRoute: exactMatch,
760
+ };
761
+ }
762
+ return { route: exactMatch, params: {} };
763
+ }
764
+
765
+ for (const route of this.pathToRouteMap.values()) {
766
+ if (route.compiled) {
767
+ const { regex, paramNames } = route.compiled;
768
+ const match = path.match(regex);
769
+ if (match) {
770
+ const params = {};
771
+ paramNames.forEach((name, i) => {
772
+ params[name] = match[i + 1];
773
+ });
774
+
775
+ if (route.parentRoute) {
776
+ return {
777
+ route: route.parentRoute,
778
+ params: params,
779
+ childRoute: route,
780
+ };
781
+ }
782
+
783
+ return { route, params };
784
+ }
785
+ }
786
+ }
787
+
788
+ const notFoundRoute = this.pathToRouteMap.get('/404');
789
+ return { route: notFoundRoute, params: {} };
790
+ }
791
+
792
+ /**
793
+ * Compile a path pattern with ${param} segments.
794
+ * @param {string} pattern
795
+ * @returns {{ regex: RegExp, paramNames: string[] }}
796
+ */
797
+ compilePathPattern(pattern) {
798
+ const paramNames = [];
799
+ const regexPattern =
800
+ '^' +
801
+ pattern.replace(/\$\{([^}]+)\}/g, (_, paramName) => {
802
+ paramNames.push(paramName);
803
+ return '([^/]+)';
804
+ }) +
805
+ '$';
806
+
807
+ // 'i' flag: paths match case-insensitively. Captured param values keep their original case.
808
+ return { regex: new RegExp(regexPattern, 'i'), paramNames };
809
+ }
810
+ }