slicejs-web-framework 4.0.0 → 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.
@@ -466,6 +466,11 @@ export default class Router {
466
466
  const componentName = route.parentRoute ? route.parentRoute.component : route.component;
467
467
  const sliceId = `route-${componentName}`;
468
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
+
469
474
  const existingComponent = slice.controller.getComponent(sliceId);
470
475
 
471
476
  if (slice.loading) {
@@ -487,6 +492,18 @@ export default class Router {
487
492
  sliceId: sliceId,
488
493
  });
489
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
+
490
507
  targetElement.innerHTML = '';
491
508
  targetElement.appendChild(component);
492
509
 
@@ -50,8 +50,21 @@ export default class ThemeManager {
50
50
  * @returns {Promise<void>}
51
51
  */
52
52
  async loadThemeCSS(themeName) {
53
- let themeContent =
54
- localStorage.getItem(`sliceTheme-${themeName}`) || (await slice.controller.fetchText(themeName, 'theme'));
53
+ let themeContent = localStorage.getItem(`sliceTheme-${themeName}`);
54
+
55
+ if (!themeContent) {
56
+ try {
57
+ themeContent = await slice.controller.fetchText(themeName, 'theme');
58
+ } catch (error) {
59
+ // A theme that fails to fetch must not abort framework init.
60
+ // applyTheme() is awaited from StylesManager.init(), which runs
61
+ // inside Slice.js init() — letting this throw leaves the runtime
62
+ // half-built (no slice.router). An unstyled app is recoverable;
63
+ // no app at all is not. fetchText already logged the cause.
64
+ slice.logger.logError('ThemeManager', `Failed to load theme: ${themeName}`, error);
65
+ return;
66
+ }
67
+ }
55
68
 
56
69
  if (!themeContent) {
57
70
  slice.logger.logError('ThemeManager', `Failed to load theme: ${themeName}`);
package/Slice/Slice.js CHANGED
@@ -311,40 +311,27 @@ export default class Slice {
311
311
  if (componentIds.id && isVisual) componentInstance.id = componentIds.id;
312
312
  if (componentIds.sliceId) componentInstance.sliceId = componentIds.sliceId;
313
313
 
314
- if (!this.controller.verifyComponentIds(componentInstance)) {
315
- this.logger.logError('Slice', `Error registering instance ${componentName} ${componentInstance.sliceId}`);
316
- return null;
317
- }
318
-
319
- // Dev guard: update() called during init() runs BEFORE the component is
320
- // registered, so the framework's update() wrapper (serialization +
321
- // liveness) isn't installed yet and the call misbehaves silently. Warn
322
- // instead. Restored right after init() so registerComponent →
323
- // installUpdate wraps the real method, not this guard.
324
- let _rawUpdate = null;
325
- if (typeof componentInstance.update === 'function') {
326
- _rawUpdate = componentInstance.update;
327
- componentInstance.update = function (...args) {
328
- slice.logger.logWarning(
329
- componentInstance.constructor?.name || 'Component',
330
- 'update() was called during init() (before registration). Run your render logic directly from init(); the update() wrapper (serialization + liveness) is not active until the component is registered.'
331
- );
332
- return _rawUpdate.apply(this, args);
333
- };
334
- }
314
+ if (!this.controller.verifyComponentIds(componentInstance)) {
315
+ this.logger.logError('Slice', `Error registering instance ${componentName} ${componentInstance.sliceId}`);
316
+ return null;
317
+ }
335
318
 
336
- if (componentInstance.init) await componentInstance.init();
319
+ // Register before init so the component is visible in activeComponents
320
+ // during init(). This lets slice.router.navigate() work from inside
321
+ // init() without the Router creating a duplicate instance (the Router
322
+ // finds the existing component via getComponent). Calling update()
323
+ // from init() is safe because installUpdate already wrapped it.
324
+ this.controller.registerComponent(componentInstance);
337
325
 
338
- if (_rawUpdate) componentInstance.update = _rawUpdate;
326
+ if (componentInstance.init) await componentInstance.init();
339
327
 
340
- if (slice.debuggerConfig.enabled && isVisual) {
341
- this.debugger.attachDebugMode(componentInstance);
342
- }
328
+ if (slice.debuggerConfig.enabled && isVisual) {
329
+ this.debugger.attachDebugMode(componentInstance);
330
+ }
343
331
 
344
- this.controller.registerComponent(componentInstance);
345
- if (isVisual) {
346
- this.controller.registerComponentsRecursively(componentInstance);
347
- }
332
+ if (isVisual) {
333
+ this.controller.registerComponentsRecursively(componentInstance);
334
+ }
348
335
 
349
336
  this.logger.logInfo('Slice', `Instance ${componentInstance.sliceId} created`);
350
337
  return componentInstance;
@@ -457,77 +444,19 @@ async function init() {
457
444
  }
458
445
  }
459
446
 
460
- // 5. Create Slice instance and set resolved mode
461
- window.slice = new Slice(sliceConfig, frameworkClasses);
462
- window.slice._mode = resolvedMode;
463
- window.slice.setPublicEnv(envResult?.env || {});
464
-
465
- const createBundlingInitError = (step, error) => {
466
- const detail = error instanceof Error ? error.message : String(error);
467
- return new Error(`Bundling V2 initialization failed (${step}): ${detail}`, { cause: error });
468
- };
469
-
470
- // Initialize bundles before building components.
471
- // Only in production — dev mode loads each component individually from source.
472
- // bundleConfigJson was already fetched above (step 2); reuse it.
473
- if (resolvedMode === 'production' && bundleConfigJson) {
474
- window.slice.controller.bundleConfig = bundleConfigJson;
475
- }
476
-
477
- if (resolvedMode === 'production' && window.slice.controller.bundleConfig) {
478
- const config = window.slice.controller.bundleConfig;
479
- if (!window.__SLICE_SHARED_DEPS__ || typeof window.__SLICE_SHARED_DEPS__ !== 'object') {
480
- window.__SLICE_SHARED_DEPS__ = {};
481
- }
482
- const criticalFile = config?.bundles?.critical?.file;
483
- if (criticalFile) {
484
- try {
485
- await window.slice.controller.loadBundle('critical');
486
- } catch (error) {
487
- throw createBundlingInitError(`critical bundle "${criticalFile}"`, error);
488
- }
489
- }
490
-
491
- const routeBundles = config?.routeBundles || {};
492
- const initialPath = window.location.pathname || '/';
493
- const bundlesForRoute = routeBundles[initialPath] || [];
494
-
495
- const loadRouteBundles = async () => {
496
- for (const bundleName of bundlesForRoute) {
497
- if (bundleName === 'critical') continue;
498
- const bundleInfo = config?.bundles?.routes?.[bundleName];
499
- if (!bundleInfo?.file) continue;
500
- await window.slice.controller.loadBundle(bundleName);
501
- }
502
- };
503
-
504
- const preloadRouteBundles = () => {
505
- loadRouteBundles().catch((error) => {
506
- window.slice?.logger?.error('Slice', `Idle route preload failed for "${initialPath}"`, error);
507
- });
508
- };
509
-
510
- const safePreload = () => {
511
- try {
512
- preloadRouteBundles();
513
- } catch (error) {
514
- window.slice?.logger?.error('Slice', 'Error in route preload callback', error);
515
- }
516
- };
517
-
518
- if (typeof requestIdleCallback === 'function') {
519
- requestIdleCallback(() => safePreload());
520
- } else {
521
- setTimeout(() => safePreload(), 0);
522
- }
523
- }
447
+ // 5. Create Slice instance and set resolved mode
448
+ window.slice = new Slice(sliceConfig, frameworkClasses);
449
+ window.slice._mode = resolvedMode;
450
+ window.slice.setPublicEnv(envResult?.env || {});
524
451
 
525
452
  slice.paths.structuralComponentFolderPath = '/Slice/Components/Structural';
526
453
 
527
- if (sliceConfig.logger.enabled) {
528
- const LoggerModule = window.slice.frameworkClasses?.Logger
529
- || await window.slice.getClass(`${slice.paths.structuralComponentFolderPath}/Logger/Logger.js`);
530
- window.slice.logger = new LoggerModule();
454
+ // Logger must be available before bundle loading — Controller internally calls
455
+ // slice.logger.* methods during loadBundle/registerBundle.
456
+ if (sliceConfig.logger.enabled) {
457
+ const LoggerModule = window.slice.frameworkClasses?.Logger
458
+ || await window.slice.getClass(`${slice.paths.structuralComponentFolderPath}/Logger/Logger.js`);
459
+ window.slice.logger = new LoggerModule();
531
460
  } else {
532
461
  const noop = () => {};
533
462
  window.slice.logger = {
@@ -536,6 +465,66 @@ async function init() {
536
465
  };
537
466
  }
538
467
 
468
+ const createBundlingInitError = (step, error) => {
469
+ const detail = error instanceof Error ? error.message : String(error);
470
+ return new Error(`Bundling V2 initialization failed (${step}): ${detail}`, { cause: error });
471
+ };
472
+
473
+ // Initialize bundles before building components.
474
+ // Only in production — dev mode loads each component individually from source.
475
+ // bundleConfigJson was already fetched above (step 2); reuse it.
476
+ if (resolvedMode === 'production' && bundleConfigJson) {
477
+ window.slice.controller.bundleConfig = bundleConfigJson;
478
+ }
479
+
480
+ if (resolvedMode === 'production' && window.slice.controller.bundleConfig) {
481
+ const config = window.slice.controller.bundleConfig;
482
+ if (!window.__SLICE_SHARED_DEPS__ || typeof window.__SLICE_SHARED_DEPS__ !== 'object') {
483
+ window.__SLICE_SHARED_DEPS__ = {};
484
+ }
485
+ const criticalFile = config?.bundles?.critical?.file;
486
+ if (criticalFile) {
487
+ try {
488
+ await window.slice.controller.loadBundle('critical');
489
+ } catch (error) {
490
+ throw createBundlingInitError(`critical bundle "${criticalFile}"`, error);
491
+ }
492
+ }
493
+
494
+ const routeBundles = config?.routeBundles || {};
495
+ const initialPath = window.location.pathname || '/';
496
+ const bundlesForRoute = routeBundles[initialPath] || [];
497
+
498
+ const loadRouteBundles = async () => {
499
+ for (const bundleName of bundlesForRoute) {
500
+ if (bundleName === 'critical') continue;
501
+ const bundleInfo = config?.bundles?.routes?.[bundleName];
502
+ if (!bundleInfo?.file) continue;
503
+ await window.slice.controller.loadBundle(bundleName);
504
+ }
505
+ };
506
+
507
+ const preloadRouteBundles = () => {
508
+ loadRouteBundles().catch((error) => {
509
+ window.slice?.logger?.error('Slice', `Idle route preload failed for "${initialPath}"`, error);
510
+ });
511
+ };
512
+
513
+ const safePreload = () => {
514
+ try {
515
+ preloadRouteBundles();
516
+ } catch (error) {
517
+ window.slice?.logger?.error('Slice', 'Error in route preload callback', error);
518
+ }
519
+ };
520
+
521
+ if (typeof requestIdleCallback === 'function') {
522
+ requestIdleCallback(() => safePreload());
523
+ } else {
524
+ setTimeout(() => safePreload(), 0);
525
+ }
526
+ }
527
+
539
528
  if (sliceConfig.debugger.enabled) {
540
529
  const DebuggerModule = window.slice.frameworkClasses?.Debugger
541
530
  || await window.slice.getClass(`${slice.paths.structuralComponentFolderPath}/Debugger/Debugger.js`);
@@ -691,8 +680,31 @@ async function init() {
691
680
  await window.slice.router.init();
692
681
  }
693
682
 
694
- try {
695
- await init();
696
- } catch (initError) {
697
- console.error('[Slice.js] Initialization failed:', initError);
698
- }
683
+ // Booting requires a DOM. Under node — where this module is imported for the
684
+ // Slice class alone (see Slice/tests) — there is nothing to initialize, so skip
685
+ // it rather than starting an init() that can only fail.
686
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
687
+ // Exposed so callers that cannot rely on module import order — test
688
+ // harnesses, embedders, anything loaded via a plain <script> — have a real
689
+ // readiness signal. It settles exactly when init() does. Note that
690
+ // `window.slice` and `slice.build` exist well before init() finishes, so
691
+ // checking for those is NOT a readiness test; await this (or check
692
+ // `slice.router`) instead.
693
+ window.__SLICE_READY__ = init();
694
+
695
+ try {
696
+ await window.__SLICE_READY__;
697
+ if (window.slice) window.slice.initError = null;
698
+ } catch (initError) {
699
+ // Deliberately not swallowed. A half-initialized runtime (no
700
+ // slice.router, no registered components) makes every downstream call
701
+ // fail with its own unrelated-looking TypeError — "Cannot read
702
+ // properties of undefined (reading 'afterEach')", "appendChild:
703
+ // parameter 1 is not of type 'Node'" — each of which hides this, the
704
+ // actual cause. Record it, then rethrow so that importing this module
705
+ // fails loudly and on the real error.
706
+ if (window.slice) window.slice.initError = initError;
707
+ console.error('[Slice.js] Initialization failed:', initError);
708
+ throw initError;
709
+ }
710
+ }
@@ -707,7 +707,16 @@ test('Slice init fails fast with contextual error on invalid Bundling V2 contrac
707
707
 
708
708
  const sliceModuleUrl = new URL(`../Slice.js?fail-fast-test=${Date.now()}`, import.meta.url).href;
709
709
 
710
- await import(sliceModuleUrl);
710
+ // Failing fast means the failure propagates: a rejected import stops
711
+ // consumers from running against a runtime that has no router and no
712
+ // registered components. Previously init() logged and resolved anyway,
713
+ // so the first symptom a developer saw was an unrelated TypeError
714
+ // further downstream rather than this contextual error.
715
+ await assert.rejects(
716
+ () => import(sliceModuleUrl),
717
+ /Bundling V2 initialization failed/,
718
+ 'a failed init must reject the module import, not resolve a half-built runtime'
719
+ );
711
720
 
712
721
  assert.ok(
713
722
  loggedMessages.some((message) => /Bundling V2 initialization failed/i.test(message)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-web-framework",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -0,0 +1,125 @@
1
+ // Regression test: handleRoute() must not append a failed build to the DOM.
2
+ //
3
+ // slice.build() returns null on any unrecoverable path (component not
4
+ // registered, a resource fetch failed or was aborted mid-flight, init() threw).
5
+ // Passing that null straight to appendChild() raised
6
+ // TypeError: Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'
7
+ // which surfaced far from the real cause and made the failure look like a DOM
8
+ // bug. handleRoute() now reports the build failure and leaves the current view
9
+ // in place.
10
+ //
11
+ // Router has no imports and no module-level DOM access, so its prototype can be
12
+ // exercised directly against stubs (same approach as router-emit-ordering).
13
+ //
14
+ // Run: node --test tests/router-build-failure.test.js
15
+
16
+ import { test, describe, before, beforeEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+
19
+ describe('Router.handleRoute — failed component build', () => {
20
+ let Router;
21
+ let appended;
22
+ let loggedErrors;
23
+ let appElement;
24
+
25
+ before(async () => {
26
+ globalThis.window = globalThis.window || { location: { pathname: '/' } };
27
+ globalThis.slice = globalThis.slice || {};
28
+ ({ default: Router } = await import('../Slice/Components/Structural/Router/Router.js'));
29
+ });
30
+
31
+ beforeEach(() => {
32
+ appended = [];
33
+ loggedErrors = [];
34
+ appElement = {
35
+ innerHTML: 'previous view',
36
+ appendChild(node) {
37
+ // Mirror the real DOM contract: appending a non-Node is a TypeError.
38
+ if (!node || typeof node !== 'object') {
39
+ throw new TypeError(
40
+ "Failed to execute 'appendChild' on 'Node': parameter 1 is not of type 'Node'."
41
+ );
42
+ }
43
+ appended.push(node);
44
+ },
45
+ };
46
+
47
+ globalThis.document = { querySelector: (sel) => (sel === '#app' ? appElement : null) };
48
+ globalThis.slice = {
49
+ logger: { logError: (...args) => loggedErrors.push(args) },
50
+ controller: { getComponent: () => null },
51
+ router: {},
52
+ loading: null,
53
+ build: async () => null, // the failure under test
54
+ };
55
+ });
56
+
57
+ const makeRouter = () => {
58
+ const r = Object.create(Router.prototype);
59
+ r.renderRoutesInComponent = async () => {};
60
+ r.invalidateCache = () => {};
61
+ return r;
62
+ };
63
+
64
+ test('does not throw when slice.build() returns null', async () => {
65
+ const r = makeRouter();
66
+ await r.handleRoute({ path: '/broken', component: 'Broken' }, {});
67
+ assert.equal(appended.length, 0, 'nothing should be appended');
68
+ });
69
+
70
+ test('logs the build failure naming the route and component', async () => {
71
+ const r = makeRouter();
72
+ await r.handleRoute({ path: '/broken', component: 'Broken' }, {});
73
+
74
+ assert.equal(loggedErrors.length, 1, 'exactly one error should be reported');
75
+ const [scope, message] = loggedErrors[0];
76
+ assert.equal(scope, 'Router');
77
+ assert.match(message, /\/broken/);
78
+ assert.match(message, /Broken/);
79
+ });
80
+
81
+ test('leaves the current view untouched instead of blanking it', async () => {
82
+ const r = makeRouter();
83
+ await r.handleRoute({ path: '/broken', component: 'Broken' }, {});
84
+ assert.equal(appElement.innerHTML, 'previous view');
85
+ });
86
+
87
+ test('does not mark the failed route as active', async () => {
88
+ const r = makeRouter();
89
+ await r.handleRoute({ path: '/broken', component: 'Broken' }, {});
90
+ assert.equal(slice.router.activeRoute, undefined);
91
+ });
92
+
93
+ test('still stops the loading indicator', async () => {
94
+ const stops = [];
95
+ slice.loading = { start() {}, stop() { stops.push(1); } };
96
+
97
+ const r = makeRouter();
98
+ await r.handleRoute({ path: '/broken', component: 'Broken' }, {});
99
+ assert.equal(stops.length, 1, 'loading.stop() must run even on the failure path');
100
+ });
101
+
102
+ test('a successful build still renders and activates the route', async () => {
103
+ const component = { tagName: 'DIV' };
104
+ slice.build = async () => component;
105
+
106
+ const r = makeRouter();
107
+ const route = { path: '/ok', component: 'Ok' };
108
+ await r.handleRoute(route, {});
109
+
110
+ assert.deepEqual(appended, [component]);
111
+ assert.equal(appElement.innerHTML, '');
112
+ assert.equal(slice.router.activeRoute, route);
113
+ assert.equal(loggedErrors.length, 0);
114
+ });
115
+
116
+ test('reports instead of throwing when #app is missing', async () => {
117
+ globalThis.document = { querySelector: () => null };
118
+
119
+ const r = makeRouter();
120
+ await r.handleRoute({ path: '/ok', component: 'Ok' }, {});
121
+
122
+ assert.equal(loggedErrors.length, 1);
123
+ assert.match(loggedErrors[0][1], /#app/);
124
+ });
125
+ });