a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals

feat: centralized error boundary system (#9)

* feat: error boundary
* fix: console mock & test expectations

authored by Owais and committed by GitHub 879c684b ad65ebb7

+85 -39
ROADMAP.md
··· 19 19 | v0.4.0 | ✓ | [Animation & Transitions](#animation--transitions) | 20 20 | v0.5.0 | ✓ | [Navigation & History API Routing](#navigation--history-api-routing) | 21 21 | | ✓ | [Refactor](#evaluator--binder-hardening) | 22 + | v0.5.1 | ✓ | [Error Handling & Diagnostics](#error-handling--diagnostics) | 23 + | v0.5.2 | | | 24 + | v0.5.3 | | | 25 + | v0.5.4 | | | 26 + | v0.6.1 | | [Persistence & Offline](#persistence--offline) | 27 + | v0.6.2 | | | 28 + | v0.6.3 | | | 29 + | v0.6.4 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | 30 + | v0.6.5 | | | 31 + | v0.6.6 | | | 32 + | v0.6.7 | | [Streaming & Patch Engine](#streaming--patch-engine) | 33 + | v0.6.8 | | | 34 + | v0.6.9 | | | 35 + | v0.6.10 | | | 36 + | v0.7.0 | | | 37 + | v0.8.0 | | Support `voltx-` & `vx-` attributes: recommend `vx-` | 38 + | | | Switch to `data-voltx` | 22 39 | | | Update demo to be a multi page application with routing plugin | 23 - | v0.5.1 | | Support `voltx-` & `vx-` attributes: recommend `vx-` | 24 - | v0.5.2 | | Switch to `data-voltx` | 25 - | v0.5.3 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | 26 - | v0.5.4 | | [Streaming & Patch Engine](#streaming--patch-engine) | 27 - | v0.5.5 | | PWA Capabilities | 28 - | v0.5.6 | | [Persistence & Offline](#persistence--offline) | 29 - | | | | 30 40 | v0.9.0 | | [Inspector & Developer Tools](#inspector--developer-tools) | 31 41 | v1.0.0 | | [Stable Release](#stable-release) | 32 42 ··· 102 112 103 113 ## To-Do 104 114 115 + ### Error Handling & Diagnostics 116 + 117 + **Goal**: Provide clear, actionable feedback when runtime or directive errors occur. 118 + **Outcome**: VoltX.js surfaces developer-friendly diagnostics for expression evaluation, 119 + directive parsing, and network operations, making it easier to debug apps without opaque stack traces. 120 + **Deliverables**: 121 + - v0.5.1 122 + ✓ Centralized error boundary system for directives and effects. 123 + ✓ Sandbox error wrapping with contextual hints (directive name, expression, element). 124 + ✓ `$volt.report(error, context)` API for plugin and app-level reporting. 125 + - v0.5.2 126 + - Visual in-DOM error overlays for development mode. 127 + - Enhanced console messages with source map trace and directive path. 128 + - Differentiated error levels: warn, error, fatal. 129 + - v0.5.3 130 + - Runtime health monitor tracking evaluation and subscription failures. 131 + - v0.5.4 132 + - Documentation: "Understanding VoltX Errors" guide. 133 + - Configurable global error policy (silent, overlay, throw). 134 + 105 135 ### Streaming & Patch Engine 106 136 107 137 **Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching. 108 138 **Outcome:** VoltX.js can receive and apply live updates from the server 109 139 **Deliverables:** 110 - - Server-Sent Events (SSE) integration 111 - - `data-volt-flow` attribute for SSE endpoints 112 - - Signal patching from backend (`data-signals-*` merge system) 113 - - Backend action system with `$$spark()` syntax 114 - - JSON Patch parser and DOM morphing engine 115 - - WebSocket as alternative to SSE 116 - - `data-volt-ignore-morph` for selective patch exclusion 140 + - v0.5.7 141 + - Server-Sent Events (SSE) integration 142 + - `data-volt-flow` attribute for SSE endpoints 143 + - v0.5.8 144 + - Signal patching from backend (`data-signals-*` merge system) 145 + - Backend action system with `$$spark()` syntax 146 + - v0.5.9 147 + - JSON Patch parser and DOM morphing engine 148 + - `data-volt-ignore-morph` for selective patch exclusion 149 + - v0.5.10 150 + - WebSocket as alternative to SSE 117 151 118 152 ### Persistence & Offline 119 153 ··· 122 156 **Deliverables:** 123 157 - ✓ Persistent signals (localStorage, sessionStorage, indexedDb) 124 158 - ✓ Storage plugin (`data-volt-persist`) 125 - - Storage modifiers on signals: 126 - - `.local` modifier for localStorage persistence 127 - - `.session` modifier for sessionStorage persistence 128 - - `.ifmissing` modifier for conditional initialization 129 - - Offline queue for deferred stream events and HTTP requests 130 - - Sync strategy API (merge, overwrite, patch) for conflict resolution 131 - - Service Worker integration for offline-first apps 132 - - Background sync for deferred requests 133 - - Cache invalidation strategies 134 - - Cross-tab synchronization via `BroadcastChannel` 159 + - v0.5.1 160 + - Storage modifiers on signals: 161 + - `.local` modifier for localStorage persistence 162 + - `.session` modifier for sessionStorage persistence 163 + - `.ifmissing` modifier for conditional initialization 164 + - v0.5.2 165 + - Sync strategy API (merge, overwrite, patch) for conflict resolution 166 + - Cache invalidation strategies 167 + - v0.5.3 168 + - Offline queue for deferred stream events and HTTP requests 169 + - Service Worker integration for offline-first apps 170 + - Background sync for deferred requests 171 + - Cross-tab synchronization via `BroadcastChannel` 135 172 136 173 ### Background Requests & Reactive Polling 137 174 138 175 **Goal:** Enable declarative background data fetching and periodic updates within the VoltX.js runtime. 139 176 **Outcome:** VoltX.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions. 140 177 **Deliverables:** 141 - - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) 142 - - `data-volt-fetch` attribute for declarative background requests 143 - - Configurable polling intervals, delays, and signal-based triggers 144 - - Automatic cancellation of requests when elements are unmounted 145 - - Conditional execution tied to reactive signals 146 - - Integration hooks for loading and pending states 147 - - Background task scheduler with priority management 178 + - v0.5.4 179 + - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) 180 + - v0.5.5 181 + - `data-volt-fetch` attribute for declarative background requests 182 + - Configurable polling intervals, delays, and signal-based triggers 183 + - Automatic cancellation of requests when elements are unmounted 184 + - Conditional execution tied to reactive signals 185 + - Integration hooks for loading and pending states 186 + - v0.5.6 187 + - Background task scheduler with priority management 148 188 149 189 ### Inspector & Developer Tools 150 190 151 191 **Goal:** Improve developer experience and runtime introspection. 152 192 **Outcome:** First-class developer ergonomics; VoltX.js is enjoyable to debug and extend. 153 193 **Deliverables:** 154 - - Developer overlay for inspecting signals, subscriptions, and effects 155 - - Dev logging toggle (`Volt.debug = true`) 156 - - Browser console integration (`window.$volt.inspect()`) 157 - - Signal dependency graph visualization (graph data structure implemented in [proxy](#proxy-based-reactivity-enhancements) milestone) 158 - - Performance profiling tools 159 - - Request/response debugging (HTTP actions, SSE streams) 160 - - Time-travel debugging for signal history 161 - - Browser DevTools extension 194 + - v0.9.1 195 + - Developer overlay for inspecting signals, subscriptions, and effects 196 + - Time-travel debugging for signal history 197 + - v0.9.2 198 + - Signal dependency graph visualization (graph data structure implemented in [proxy](#proxy-based-reactivity-enhancements) milestone) 199 + - v0.9.3 200 + - Browser console integration (`window.$volt.inspect()`) 201 + - Dev logging toggle (`Volt.debug = true`) 202 + - v0.9.4 203 + - Request/response debugging (HTTP actions, SSE streams) 204 + - v0.9.5 205 + - Performance profiling tools 206 + - v0.9.6 to v0.9.10 207 + - Browser DevTools extension 162 208 163 209 ### Stable Release 164 210
+4 -3
lib/src/core/async-effect.ts
··· 4 4 5 5 import type { Optional, Timer } from "$types/helpers"; 6 6 import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "$types/volt"; 7 + import { report } from "./error"; 7 8 8 9 /** 9 10 * Creates an async side effect that runs when dependencies change. ··· 73 74 try { 74 75 cleanup(); 75 76 } catch (error) { 76 - console.error("Error in async effect cleanup:", error); 77 + report(error as Error, { source: "effect" }); 77 78 } 78 79 cleanup = undefined; 79 80 } ··· 115 116 await executeEffect(currentExecutionId); 116 117 } 117 118 } else { 118 - console.error("Error in async effect:", err); 119 + report(err as Error, { source: "effect" }); 119 120 120 121 if (onError) { 121 122 const retry = () => { ··· 198 199 try { 199 200 cleanup(); 200 201 } catch (error) { 201 - console.error("Error during async effect unmount:", error); 202 + report(error as Error, { source: "effect" }); 202 203 } 203 204 cleanup = undefined; 204 205 }
+54 -15
lib/src/core/binder.ts
··· 16 16 } from "$types/volt"; 17 17 import { BOOLEAN_ATTRS } from "./constants"; 18 18 import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 19 + import { report } from "./error"; 19 20 import { evaluate } from "./evaluator"; 20 21 import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 21 22 import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; ··· 132 133 try { 133 134 cleanup(); 134 135 } catch (error) { 135 - console.error("Error during unmount:", error); 136 + report(error as Error, { source: "binding", element: root as HTMLElement }); 136 137 } 137 138 } 138 139 ··· 145 146 try { 146 147 plugin(pluginCtx, val); 147 148 } catch (error) { 148 - console.error(`Error in plugin "${base}":`, error); 149 + report(error as Error, { 150 + source: "plugin", 151 + element: ctx.element as HTMLElement, 152 + directive: `data-volt-${base}`, 153 + pluginName: base, 154 + }); 149 155 } 150 156 } 151 157 ··· 227 233 break; 228 234 } 229 235 default: { 230 - // Check directive registry first (for HTTP and other optional directives) 231 236 const directiveHandler = directiveRegistry.get(baseName); 232 237 if (directiveHandler) { 233 238 directiveHandler(ctx, value, modifiers); 234 239 return; 235 240 } 236 241 237 - // Then check plugin registry 238 242 const plugin = getPlugin(baseName); 239 243 if (plugin) { 240 244 execPlugin(plugin, ctx, value, baseName); ··· 374 378 try { 375 379 element.style.setProperty(cssKey, String(val)); 376 380 } catch (error) { 377 - console.warn(`[Volt] Failed to set style property "${cssKey}":`, error); 381 + report(error as Error, { 382 + source: "binding", 383 + element: element, 384 + directive: "data-volt-style", 385 + expression: expr, 386 + }); 378 387 } 379 388 } 380 389 } ··· 458 467 result(event); 459 468 } 460 469 } catch (error) { 461 - console.error(`Error in event handler (${eventName}):`, error); 470 + report(error as Error, { 471 + source: "binding", 472 + element: ctx.element as HTMLElement, 473 + directive: `data-volt-on-${eventName}`, 474 + expression: expr, 475 + }); 462 476 } 463 477 }; 464 478 ··· 598 612 function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void { 599 613 const result = findModelSignal(context.scope, signalPath); 600 614 if (!result) { 601 - console.error(`Signal "${signalPath}" not found for data-volt-model`); 615 + report(new Error(`Signal "${signalPath}" not found`), { 616 + source: "binding", 617 + element: context.element as HTMLElement, 618 + directive: "data-volt-model", 619 + expression: signalPath, 620 + }); 602 621 return; 603 622 } 604 623 ··· 766 785 evaluate(stmt, ctx.scope, { unwrapSignals: false }); 767 786 } 768 787 } catch (error) { 769 - console.error("Error in data-volt-init:", error); 788 + report(error as Error, { 789 + source: "binding", 790 + element: ctx.element as HTMLElement, 791 + directive: "data-volt-init", 792 + expression: expr, 793 + }); 770 794 } 771 795 } 772 796 ··· 791 815 function bindFor(ctx: BindingContext, expr: string): void { 792 816 const parsed = parseForExpr(expr); 793 817 if (!parsed) { 794 - console.error(`Invalid data-volt-for expression: "${expr}"`); 818 + report(new Error(`Invalid data-volt-for expression: "${expr}"`), { 819 + source: "binding", 820 + element: ctx.element as HTMLElement, 821 + directive: "data-volt-for", 822 + expression: expr, 823 + }); 795 824 return; 796 825 } 797 826 ··· 800 829 const parent = templ.parentElement; 801 830 802 831 if (!parent) { 803 - console.error("data-volt-for element must have a parent"); 832 + report(new Error("data-volt-for element must have a parent"), { 833 + source: "binding", 834 + element: ctx.element as HTMLElement, 835 + directive: "data-volt-for", 836 + expression: expr, 837 + }); 804 838 return; 805 839 } 806 840 ··· 863 897 const parent = ifTempl.parentElement; 864 898 865 899 if (!parent) { 866 - console.error("data-volt-if element must have a parent"); 900 + report(new Error("data-volt-if element must have a parent"), { 901 + source: "binding", 902 + element: ctx.element as HTMLElement, 903 + directive: "data-volt-if", 904 + expression: expr, 905 + }); 867 906 return; 868 907 } 869 908 ··· 1034 1073 try { 1035 1074 cb(); 1036 1075 } catch (error) { 1037 - console.error("Error in plugin onMount hook:", error); 1076 + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onMount" }); 1038 1077 } 1039 1078 }, 1040 1079 onUnmount: (cb: () => void) => { ··· 1045 1084 try { 1046 1085 cb(); 1047 1086 } catch (error) { 1048 - console.error("Error in plugin beforeBinding hook:", error); 1087 + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "beforeBinding" }); 1049 1088 } 1050 1089 }, 1051 1090 afterBinding: (cb: () => void) => { ··· 1054 1093 try { 1055 1094 cb(); 1056 1095 } catch (error) { 1057 - console.error("Error in plugin afterBinding hook:", error); 1096 + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "afterBinding" }); 1058 1097 } 1059 1098 }); 1060 1099 }, ··· 1065 1104 try { 1066 1105 cb(); 1067 1106 } catch (error) { 1068 - console.error("Error in plugin onUnmount hook:", error); 1107 + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onUnmount" }); 1069 1108 } 1070 1109 } 1071 1110 });
+28 -9
lib/src/core/charge.ts
··· 6 6 7 7 import type { ChargedRoot, ChargeResult, Scope } from "$types/volt"; 8 8 import { mount } from "./binder"; 9 + import { report } from "./error"; 9 10 import { evaluate } from "./evaluator"; 10 11 import { getComputedAttributes, isNil } from "./shared"; 11 12 import { computed, signal } from "./signal"; ··· 13 14 14 15 /** 15 16 * Discover and mount all Volt roots in the document. 17 + * 16 18 * Parses data-volt-state for initial state and data-volt-computed for derived values. 17 19 * Also parses declarative global store from script[data-volt-store] elements. 18 20 * ··· 54 56 55 57 chargedRoots.push({ element, scope, cleanup }); 56 58 } catch (error) { 57 - console.error("Error charging Volt root:", element, error); 59 + report(error as Error, { source: "charge", element: element as HTMLElement }); 58 60 } 59 61 } 60 62 ··· 65 67 try { 66 68 root.cleanup(); 67 69 } catch (error) { 68 - console.error("Error cleaning up Volt root:", root.element, error); 70 + report(error as Error, { source: "charge", element: root.element as HTMLElement }); 69 71 } 70 72 } 71 73 }, ··· 84 86 const stateData = JSON.parse(stateAttr); 85 87 86 88 if (typeof stateData !== "object" || isNil(stateData) || Array.isArray(stateData)) { 87 - console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); 89 + report(new Error(`data-volt-state must be a JSON object, got ${typeof stateData}`), { 90 + source: "charge", 91 + element: el as HTMLElement, 92 + directive: "data-volt-state", 93 + expression: stateAttr, 94 + }); 88 95 } else { 89 96 for (const [key, value] of Object.entries(stateData)) { 90 97 scope[key] = signal(value); 91 98 } 92 99 } 93 100 } catch (error) { 94 - console.error("Failed to parse data-volt-state JSON:", stateAttr, error); 95 - console.error("Element:", el); 101 + report(error as Error, { 102 + source: "charge", 103 + element: el as HTMLElement, 104 + directive: "data-volt-state", 105 + expression: stateAttr, 106 + }); 96 107 } 97 108 } 98 109 ··· 101 112 try { 102 113 scope[name] = computed(() => evaluate(expression, scope)); 103 114 } catch (error) { 104 - console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 115 + report(error as Error, { 116 + source: "charge", 117 + element: el as HTMLElement, 118 + directive: `data-volt-computed:${name}`, 119 + expression: expression, 120 + }); 105 121 } 106 122 } 107 123 ··· 125 141 const data = JSON.parse(content); 126 142 127 143 if (typeof data !== "object" || isNil(data) || Array.isArray(data)) { 128 - console.error("data-volt-store script must contain a JSON object, got:", typeof data); 144 + report(new Error(`data-volt-store script must contain a JSON object, got: ${typeof data}`), { 145 + source: "charge", 146 + element: script as HTMLElement, 147 + directive: "data-volt-store", 148 + }); 129 149 continue; 130 150 } 131 151 132 152 registerStore(data); 133 153 } catch (error) { 134 - console.error("Failed to parse data-volt-store script:", error); 135 - console.error("Script element:", script); 154 + report(error as Error, { source: "charge", element: script as HTMLElement, directive: "data-volt-store" }); 136 155 } 137 156 } 138 157 }
+246
lib/src/core/error.ts
··· 1 + /** 2 + * Core error handling and reporting system for VoltX.js 3 + * 4 + * Provides centralized error boundary with rich contextual information 5 + * for debugging directives, expressions, effects, and HTTP operations. 6 + * 7 + * @module core/error 8 + */ 9 + import type { ErrorContext, ErrorHandler, ErrorSource } from "$types/volt"; 10 + 11 + /** 12 + * Enhanced error class with VoltX context 13 + * 14 + * Wraps original errors with rich debugging information including 15 + * source, element, directive, and expression details. 16 + */ 17 + export class VoltError extends Error { 18 + /** Error source category */ 19 + public readonly source: ErrorSource; 20 + /** DOM element where error occurred */ 21 + public readonly element?: HTMLElement; 22 + /** Directive name */ 23 + public readonly directive?: string; 24 + /** Expression that failed */ 25 + public readonly expression?: string; 26 + /** Original error */ 27 + public readonly cause: Error; 28 + /** When error occurred */ 29 + public readonly timestamp: number; 30 + /** Full error context */ 31 + public readonly context: ErrorContext; 32 + /** Whether propagation was stopped */ 33 + private _stopped: boolean = false; 34 + 35 + constructor(cause: Error, context: ErrorContext) { 36 + const message = VoltError.buildMessage(cause, context); 37 + super(message); 38 + this.name = "VoltError"; 39 + this.cause = cause; 40 + this.source = context.source; 41 + this.element = context.element; 42 + this.directive = context.directive; 43 + this.expression = context.expression; 44 + this.context = context; 45 + this.timestamp = Date.now(); 46 + 47 + if (Error.captureStackTrace) { 48 + Error.captureStackTrace(this); 49 + } 50 + } 51 + 52 + /** 53 + * Stop propagation to subsequent error handlers 54 + */ 55 + public stopPropagation(): void { 56 + this._stopped = true; 57 + } 58 + 59 + /** 60 + * Check if propagation was stopped 61 + */ 62 + public get stopped(): boolean { 63 + return this._stopped; 64 + } 65 + 66 + private static buildMessage(cause: Error, context: ErrorContext): string { 67 + const parts: string[] = []; 68 + 69 + parts.push(`[${context.source}] ${cause.message}`); 70 + 71 + if (context.directive) { 72 + parts.push(`Directive: ${context.directive}`); 73 + } 74 + 75 + if (context.expression) { 76 + const truncated = context.expression.length > 100 ? `${context.expression.slice(0, 100)}...` : context.expression; 77 + parts.push(`Expression: ${truncated}`); 78 + } 79 + 80 + if (context.pluginName) { 81 + parts.push(`Plugin: ${context.pluginName}`); 82 + } 83 + 84 + if (context.httpMethod && context.httpUrl) { 85 + parts.push(`HTTP: ${context.httpMethod} ${context.httpUrl}`); 86 + if (context.httpStatus) { 87 + parts.push(`Status: ${context.httpStatus}`); 88 + } 89 + } 90 + 91 + if (context.hookName) { 92 + parts.push(`Hook: ${context.hookName}`); 93 + } 94 + 95 + if (context.element) { 96 + const tag = context.element.tagName.toLowerCase(); 97 + const id = context.element.id ? `#${context.element.id}` : ""; 98 + const cls = context.element.className ? `.${context.element.className.split(" ").join(".")}` : ""; 99 + parts.push(`Element: <${tag}${id}${cls}>`); 100 + } 101 + 102 + return parts.join(" | "); 103 + } 104 + 105 + /** 106 + * Serialize error for logging/reporting 107 + */ 108 + public toJSON(): Record<string, unknown> { 109 + return { 110 + name: this.name, 111 + message: this.message, 112 + source: this.source, 113 + directive: this.directive, 114 + expression: this.expression, 115 + timestamp: this.timestamp, 116 + context: this.context, 117 + cause: { name: this.cause.name, message: this.cause.message, stack: this.cause.stack }, 118 + stack: this.stack, 119 + }; 120 + } 121 + } 122 + 123 + /** 124 + * Global error handler registry 125 + */ 126 + let errorHandlers: ErrorHandler[] = []; 127 + 128 + /** 129 + * Register an error handler 130 + * 131 + * Multiple handlers can be registered and will be called in registration order. 132 + * Handlers can call `error.stopPropagation()` to prevent subsequent handlers 133 + * from being called. 134 + * 135 + * @param handler - Error handler function 136 + * @returns Cleanup function to unregister the handler 137 + * 138 + * @example 139 + * ```ts 140 + * const cleanup = onError((error) => { 141 + * console.log('Error source:', error.source); 142 + * console.log('Element:', error.element); 143 + * console.log('Expression:', error.expression); 144 + * 145 + * // Stop other handlers from running 146 + * if (error.source === "http") { 147 + * error.stopPropagation(); 148 + * } 149 + * }); 150 + * 151 + * // Later: cleanup() 152 + * ``` 153 + */ 154 + export function onError(handler: ErrorHandler): () => void { 155 + errorHandlers.push(handler); 156 + return () => { 157 + errorHandlers = errorHandlers.filter((h) => h !== handler); 158 + }; 159 + } 160 + 161 + /** 162 + * Clear all registered error handlers 163 + * 164 + * Useful for testing or when you want to reset error handling state. 165 + * 166 + * @example 167 + * ```ts 168 + * clearErrorHandlers(); 169 + * ``` 170 + */ 171 + export function clearErrorHandlers(): void { 172 + errorHandlers = []; 173 + } 174 + 175 + /** 176 + * Report an error through the centralized error boundary 177 + * 178 + * This function is used both internally by VoltX and externally by user code. 179 + * All errors flow through this unified system. 180 + * 181 + * If no error handlers are registered, errors are logged to console as fallback. 182 + * Once handlers are registered, console logging is disabled. 183 + * 184 + * @param error - Error to report (can be Error, unknown, or string) 185 + * @param context - Error context with source and additional details 186 + * 187 + * @example 188 + * ```ts 189 + * // Internal usage (by VoltX) 190 + * try { 191 + * evaluate(expression, scope); 192 + * } catch (err) { 193 + * report(err, { 194 + * source: ErrorSource.Evaluator, 195 + * element: ctx.element, 196 + * directive: 'data-volt-text', 197 + * expression: expression 198 + * }); 199 + * } 200 + * 201 + * // External usage (by plugins/apps) 202 + * try { 203 + * myCustomLogic(); 204 + * } catch (err) { 205 + * report(err, { 206 + * source: ErrorSource.User, 207 + * customContext: 'My feature failed' 208 + * }); 209 + * } 210 + * ``` 211 + */ 212 + export function report(error: unknown, context: ErrorContext): void { 213 + const errorObj = error instanceof Error ? error : new Error(String(error)); 214 + const voltError = new VoltError(errorObj, context); 215 + 216 + if (errorHandlers.length === 0) { 217 + console.error(voltError.message); 218 + console.error("Caused by:", voltError.cause); 219 + if (voltError.element) { 220 + console.error("Element:", voltError.element); 221 + } 222 + return; 223 + } 224 + 225 + for (const handler of errorHandlers) { 226 + try { 227 + handler(voltError); 228 + if (voltError.stopped) { 229 + break; 230 + } 231 + } catch (handlerError) { 232 + console.error("Error in error handler:", handlerError); 233 + } 234 + } 235 + } 236 + 237 + /** 238 + * Get count of registered error handlers 239 + * 240 + * Useful for testing and debugging error handling setup. 241 + * 242 + * @returns Number of registered error handlers 243 + */ 244 + export function getErrorHandlerCount(): number { 245 + return errorHandlers.length; 246 + }
+17 -4
lib/src/core/http.ts
··· 17 17 SwapStrategy, 18 18 } from "$types/volt"; 19 19 import { registerDirective } from "./binder"; 20 + import { report } from "./error"; 20 21 import { evaluate } from "./evaluator"; 21 22 import { sleep } from "./shared"; 22 23 ··· 228 229 break; 229 230 } 230 231 default: { 231 - console.error(`Unknown swap strategy: ${strategy as string}`); 232 + report(new Error(`Unknown swap strategy: ${strategy as string}`), { 233 + source: "http", 234 + element: target as HTMLElement, 235 + }); 232 236 } 233 237 } 234 238 } ··· 308 312 headers = headersValue as Record<string, string>; 309 313 } 310 314 } catch (error) { 311 - console.error("Failed to parse data-volt-headers:", error); 315 + report(error as Error, { 316 + source: "http", 317 + element: el as HTMLElement, 318 + directive: "data-volt-headers", 319 + expression: dataset.voltHeaders, 320 + }); 312 321 } 313 322 } 314 323 ··· 499 508 500 509 const target = document.querySelector(targetConf); 501 510 if (!target) { 502 - console.warn(`Target element not found: ${targetConf}`); 511 + report(new Error(`Target element not found: ${targetConf}`), { 512 + source: "http", 513 + element: defaultEl as HTMLElement, 514 + directive: "data-volt-target", 515 + }); 503 516 return undefined; 504 517 } 505 518 ··· 630 643 631 644 const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); 632 645 setErrorState(target, errorMessage, conf.indicator); 633 - console.error("HTTP request failed:", lastError); 646 + report(lastError as Error, { source: "http", element: el as HTMLElement, httpMethod: method, httpUrl: url }); 634 647 } 635 648 636 649 export function bindGet(ctx: BindingContext, url: string): void {
+4 -3
lib/src/core/lifecycle.ts
··· 4 4 */ 5 5 6 6 import type { ElementLifecycleState, GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt"; 7 + import { report } from "./error"; 7 8 8 9 /** 9 10 * Global lifecycle hooks registry ··· 124 125 (callback as UnmountHookCallback)(root); 125 126 } 126 127 } catch (error) { 127 - console.error(`Error in global ${hookName} hook:`, error); 128 + report(error as Error, { source: "lifecycle", element: root as HTMLElement, hookName: hookName }); 128 129 } 129 130 } 130 131 } ··· 181 182 try { 182 183 callback(); 183 184 } catch (error) { 184 - console.error("Error in element onMount hook:", error); 185 + report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onMount" }); 185 186 } 186 187 } 187 188 } ··· 205 206 try { 206 207 callback(); 207 208 } catch (error) { 208 - console.error("Error in element onUnmount hook:", error); 209 + report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onUnmount" }); 209 210 } 210 211 } 211 212
+8 -7
lib/src/core/signal.ts
··· 1 1 import type { ComputedSignal, Signal } from "$types/volt"; 2 + import { report } from "./error"; 2 3 import { recordDep, startTracking, stopTracking } from "./tracker"; 3 4 4 5 /** ··· 25 26 try { 26 27 callback(value); 27 28 } catch (error) { 28 - console.error("Error in signal subscriber:", error); 29 + report(error as Error, { source: "effect" }); 29 30 } 30 31 } 31 32 }; ··· 87 88 try { 88 89 cb(value); 89 90 } catch (error) { 90 - console.error("Error in computed subscriber:", error); 91 + report(error as Error, { source: "effect" }); 91 92 } 92 93 } 93 94 }; ··· 116 117 shouldNotify = subs.size > 0; 117 118 } 118 119 } catch (error) { 119 - console.error("Error in computed:", error); 120 + report(error as Error, { source: "effect" }); 120 121 throw error; 121 122 } finally { 122 123 const deps = stopTracking(); ··· 196 197 try { 197 198 cleanup(); 198 199 } catch (error) { 199 - console.error("Error in effect cleanup:", error); 200 + report(error as Error, { source: "effect" }); 200 201 } 201 202 cleanup = undefined; 202 203 } ··· 205 206 try { 206 207 cleanup = cb(); 207 208 } catch (error) { 208 - console.error("Error in effect:", error); 209 + report(error as Error, { source: "effect" }); 209 210 } finally { 210 211 const deps = stopTracking(); 211 212 ··· 225 226 try { 226 227 cleanup(); 227 228 } catch (error) { 228 - console.error("Error in effect cleanup:", error); 229 + report(error as Error, { source: "effect" }); 229 230 } 230 231 } 231 232 ··· 233 234 try { 234 235 unsubscribe(); 235 236 } catch (error) { 236 - console.error("Error unsubscribing effect:", error); 237 + report(error as Error, { source: "effect" }); 237 238 } 238 239 } 239 240 };
+14 -1
lib/src/index.ts
··· 7 7 export { asyncEffect } from "$core/async-effect"; 8 8 export { mount } from "$core/binder"; 9 9 export { charge } from "$core/charge"; 10 + export { clearErrorHandlers, onError, report } from "$core/error"; 11 + export type { VoltError } from "$core/error"; 10 12 export { parseHttpConfig, request, serializeForm, serializeFormToJSON, swap } from "$core/http"; 11 13 export { 12 14 clearAllGlobalHooks, ··· 53 55 supportsViewTransitions, 54 56 withViewTransition, 55 57 } from "$core/view-transitions"; 56 - export { goBack, goForward, getRouterMode, initNavigationListener, navigate, redirect, setRouterMode } from "$plugins/navigate"; 58 + export { 59 + getRouterMode, 60 + goBack, 61 + goForward, 62 + initNavigationListener, 63 + navigate, 64 + redirect, 65 + setRouterMode, 66 + } from "$plugins/navigate"; 57 67 export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; 58 68 export { scrollPlugin } from "$plugins/scroll"; 59 69 export { ··· 74 84 ChargedRoot, 75 85 ChargeResult, 76 86 ComputedSignal, 87 + ErrorContext, 88 + ErrorHandler, 89 + ErrorSource, 77 90 GlobalHookName, 78 91 GlobalStore, 79 92 HydrateOptions,
+34
lib/src/types/volt.d.ts
··· 552 552 */ 553 553 forceFallback?: boolean; 554 554 }; 555 + 556 + /** 557 + * Error source categories for identifying where errors occurred 558 + */ 559 + export type ErrorSource = "evaluator" | "binding" | "effect" | "http" | "plugin" | "lifecycle" | "charge" | "user"; 560 + 561 + /** 562 + * Context information for error reporting 563 + */ 564 + export type ErrorContext = { 565 + /** Error source category */ 566 + source: ErrorSource; 567 + /** DOM element where error occurred */ 568 + element?: HTMLElement; 569 + /** Directive name (e.g., "data-volt-text", "data-volt-on-click") */ 570 + directive?: string; 571 + /** Expression that failed */ 572 + expression?: string; 573 + /** Plugin name (for plugin errors) */ 574 + pluginName?: string; 575 + /** HTTP method and URL (for HTTP errors) */ 576 + httpMethod?: string; 577 + httpUrl?: string; 578 + httpStatus?: number; 579 + /** Lifecycle hook name (for lifecycle errors) */ 580 + hookName?: string; 581 + /** Additional custom context */ 582 + [key: string]: unknown; 583 + }; 584 + 585 + /** 586 + * Error handler function signature 587 + */ 588 + export type ErrorHandler = (error: VoltError) => void;
+3 -1
lib/test/core/async-effect.test.ts
··· 385 385 386 386 await vi.runAllTimersAsync(); 387 387 388 - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in async effect:", expect.any(Error)); 388 + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); 389 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[effect]")); 390 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 389 391 390 392 consoleErrorSpy.mockRestore(); 391 393 });
+300
lib/test/core/error.test.ts
··· 1 + import { clearErrorHandlers, getErrorHandlerCount, onError, report, VoltError } from "$core/error"; 2 + import type { ErrorContext, ErrorSource } from "$types/volt"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + describe("VoltError", () => { 6 + it("creates error with basic context", () => { 7 + const cause = new Error("Test error"); 8 + const context: ErrorContext = { source: "binding" }; 9 + 10 + const voltError = new VoltError(cause, context); 11 + 12 + expect(voltError).toBeInstanceOf(Error); 13 + expect(voltError).toBeInstanceOf(VoltError); 14 + expect(voltError.name).toBe("VoltError"); 15 + expect(voltError.source).toBe("binding"); 16 + expect(voltError.cause).toBe(cause); 17 + expect(voltError.stopped).toBe(false); 18 + }); 19 + 20 + it("includes directive and expression in context", () => { 21 + const cause = new Error("Evaluation failed"); 22 + const context: ErrorContext = { source: "evaluator", directive: "data-volt-text", expression: "count * 2" }; 23 + 24 + const voltError = new VoltError(cause, context); 25 + 26 + expect(voltError.directive).toBe("data-volt-text"); 27 + expect(voltError.expression).toBe("count * 2"); 28 + expect(voltError.message).toContain("[evaluator]"); 29 + expect(voltError.message).toContain("Directive: data-volt-text"); 30 + expect(voltError.message).toContain("Expression: count * 2"); 31 + }); 32 + 33 + it("includes element information in message", () => { 34 + const div = document.createElement("div"); 35 + div.id = "test"; 36 + div.className = "foo bar"; 37 + 38 + const cause = new Error("DOM error"); 39 + const context: ErrorContext = { source: "binding", element: div }; 40 + 41 + const voltError = new VoltError(cause, context); 42 + 43 + expect(voltError.element).toBe(div); 44 + expect(voltError.message).toContain("Element: <div#test.foo.bar>"); 45 + }); 46 + 47 + it("includes HTTP context in message", () => { 48 + const cause = new Error("Request failed"); 49 + const context: ErrorContext = { source: "http", httpMethod: "POST", httpUrl: "/api/users", httpStatus: 500 }; 50 + 51 + const voltError = new VoltError(cause, context); 52 + 53 + expect(voltError.message).toContain("HTTP: POST /api/users"); 54 + expect(voltError.message).toContain("Status: 500"); 55 + }); 56 + 57 + it("includes plugin name in message", () => { 58 + const cause = new Error("Plugin failed"); 59 + const context: ErrorContext = { source: "plugin", pluginName: "persist" }; 60 + 61 + const voltError = new VoltError(cause, context); 62 + 63 + expect(voltError.message).toContain("Plugin: persist"); 64 + }); 65 + 66 + it("includes lifecycle hook name in message", () => { 67 + const cause = new Error("Hook failed"); 68 + const context: ErrorContext = { source: "lifecycle", hookName: "onMount" }; 69 + 70 + const voltError = new VoltError(cause, context); 71 + 72 + expect(voltError.message).toContain("Hook: onMount"); 73 + }); 74 + 75 + it("stopPropagation prevents handler chain", () => { 76 + const cause = new Error("Test"); 77 + const context: ErrorContext = { source: "binding" }; 78 + 79 + const voltError = new VoltError(cause, context); 80 + 81 + expect(voltError.stopped).toBe(false); 82 + voltError.stopPropagation(); 83 + expect(voltError.stopped).toBe(true); 84 + }); 85 + 86 + it("serializes to JSON", () => { 87 + const cause = new Error("Test error"); 88 + const context: ErrorContext = { source: "effect", directive: "data-volt-on-click", expression: "count++" }; 89 + 90 + const voltError = new VoltError(cause, context); 91 + const json = voltError.toJSON(); 92 + 93 + expect(json.name).toBe("VoltError"); 94 + expect(json.source).toBe("effect"); 95 + expect(json.directive).toBe("data-volt-on-click"); 96 + expect(json.expression).toBe("count++"); 97 + expect(json.cause).toEqual({ name: "Error", message: "Test error", stack: cause.stack }); 98 + }); 99 + 100 + it("truncates long expressions in message", () => { 101 + const longExpr = "a".repeat(150); 102 + const cause = new Error("Test"); 103 + const context: ErrorContext = { source: "evaluator", expression: longExpr }; 104 + 105 + const voltError = new VoltError(cause, context); 106 + 107 + expect(voltError.message).toContain("Expression: " + "a".repeat(100) + "..."); 108 + expect(voltError.message).not.toContain("a".repeat(101)); 109 + }); 110 + }); 111 + 112 + describe("Error Handler Registration", () => { 113 + beforeEach(() => { 114 + clearErrorHandlers(); 115 + }); 116 + 117 + afterEach(() => { 118 + clearErrorHandlers(); 119 + }); 120 + 121 + it("registers error handler", () => { 122 + expect(getErrorHandlerCount()).toBe(0); 123 + 124 + const handler = vi.fn(); 125 + onError(handler); 126 + 127 + expect(getErrorHandlerCount()).toBe(1); 128 + }); 129 + 130 + it("returns cleanup function", () => { 131 + const handler = vi.fn(); 132 + const cleanup = onError(handler); 133 + 134 + expect(getErrorHandlerCount()).toBe(1); 135 + 136 + cleanup(); 137 + 138 + expect(getErrorHandlerCount()).toBe(0); 139 + }); 140 + 141 + it("registers multiple handlers", () => { 142 + const handler1 = vi.fn(); 143 + const handler2 = vi.fn(); 144 + 145 + onError(handler1); 146 + onError(handler2); 147 + 148 + expect(getErrorHandlerCount()).toBe(2); 149 + }); 150 + 151 + it("clears all handlers", () => { 152 + onError(vi.fn()); 153 + onError(vi.fn()); 154 + onError(vi.fn()); 155 + 156 + expect(getErrorHandlerCount()).toBe(3); 157 + 158 + clearErrorHandlers(); 159 + 160 + expect(getErrorHandlerCount()).toBe(0); 161 + }); 162 + }); 163 + 164 + describe("Error Reporting", () => { 165 + beforeEach(() => { 166 + clearErrorHandlers(); 167 + vi.spyOn(console, "error").mockImplementation(() => {}); 168 + }); 169 + 170 + afterEach(() => { 171 + clearErrorHandlers(); 172 + vi.restoreAllMocks(); 173 + }); 174 + 175 + it("calls registered handler with VoltError", () => { 176 + const handler = vi.fn(); 177 + onError(handler); 178 + 179 + const error = new Error("Test"); 180 + const context: ErrorContext = { source: "binding" }; 181 + 182 + report(error, context); 183 + 184 + expect(handler).toHaveBeenCalledTimes(1); 185 + expect(handler).toHaveBeenCalledWith(expect.any(VoltError)); 186 + 187 + const voltError = handler.mock.calls[0][0]; 188 + expect(voltError.cause).toBe(error); 189 + expect(voltError.source).toBe("binding"); 190 + }); 191 + 192 + it("calls multiple handlers in order", () => { 193 + const callOrder: number[] = []; 194 + 195 + const handler1 = vi.fn(() => callOrder.push(1)); 196 + const handler2 = vi.fn(() => callOrder.push(2)); 197 + const handler3 = vi.fn(() => callOrder.push(3)); 198 + 199 + onError(handler1); 200 + onError(handler2); 201 + onError(handler3); 202 + 203 + report(new Error("Test"), { source: "effect" }); 204 + 205 + expect(callOrder).toEqual([1, 2, 3]); 206 + }); 207 + 208 + it("stops propagation when stopPropagation is called", () => { 209 + const handler1 = vi.fn((error: VoltError) => { 210 + error.stopPropagation(); 211 + }); 212 + const handler2 = vi.fn(); 213 + const handler3 = vi.fn(); 214 + 215 + onError(handler1); 216 + onError(handler2); 217 + onError(handler3); 218 + 219 + report(new Error("Test"), { source: "effect" }); 220 + 221 + expect(handler1).toHaveBeenCalledTimes(1); 222 + expect(handler2).not.toHaveBeenCalled(); 223 + expect(handler3).not.toHaveBeenCalled(); 224 + }); 225 + 226 + it("falls back to console.error when no handlers registered", () => { 227 + const error = new Error("Test error"); 228 + const context: ErrorContext = { source: "http", httpMethod: "GET", httpUrl: "/api/data" }; 229 + 230 + report(error, context); 231 + 232 + expect(console.error).toHaveBeenCalledTimes(2); 233 + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[http]")); 234 + expect(console.error).toHaveBeenCalledWith("Caused by:", error); 235 + }); 236 + 237 + it("converts non-Error values to Error", () => { 238 + const handler = vi.fn(); 239 + onError(handler); 240 + 241 + report("string error", { source: "user" }); 242 + 243 + expect(handler).toHaveBeenCalledTimes(1); 244 + const voltError: VoltError = handler.mock.calls[0][0]; 245 + expect(voltError.cause).toBeInstanceOf(Error); 246 + expect(voltError.cause.message).toBe("string error"); 247 + }); 248 + 249 + it("catches errors in error handlers", () => { 250 + const handler1 = vi.fn(() => { 251 + throw new Error("Handler error"); 252 + }); 253 + const handler2 = vi.fn(); 254 + 255 + onError(handler1); 256 + onError(handler2); 257 + 258 + report(new Error("Test"), { source: "effect" }); 259 + 260 + expect(handler1).toHaveBeenCalledTimes(1); 261 + expect(handler2).toHaveBeenCalledTimes(1); 262 + expect(console.error).toHaveBeenCalledWith("Error in error handler:", expect.any(Error)); 263 + }); 264 + 265 + it("includes element in console fallback", () => { 266 + const div = document.createElement("div"); 267 + div.id = "test-element"; 268 + 269 + report(new Error("Test"), { source: "binding", element: div }); 270 + 271 + expect(console.error).toHaveBeenCalledWith("Element:", div); 272 + }); 273 + 274 + it("handles all error sources", () => { 275 + const handler = vi.fn(); 276 + onError(handler); 277 + 278 + const sources: Array<ErrorSource> = [ 279 + "evaluator", 280 + "binding", 281 + "effect", 282 + "http", 283 + "plugin", 284 + "lifecycle", 285 + "charge", 286 + "user", 287 + ]; 288 + 289 + for (const source of sources) { 290 + report(new Error(`Test ${source}`), { source }); 291 + } 292 + 293 + expect(handler).toHaveBeenCalledTimes(sources.length); 294 + 295 + for (const [i, source] of sources.entries()) { 296 + const voltError: VoltError = handler.mock.calls[i][0]; 297 + expect(voltError.source).toBe(source); 298 + } 299 + }); 300 + });
+8 -2
lib/test/core/lifecycle.test.ts
··· 245 245 mount(root, {}); 246 246 }).not.toThrow(); 247 247 248 - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in global beforeMount hook:", expect.any(Error)); 248 + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 249 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]")); 250 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 251 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", root); 249 252 250 253 consoleErrorSpy.mockRestore(); 251 254 }); ··· 331 334 }); 332 335 333 336 notifyElementMounted(element); 334 - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in element onMount hook:", expect.any(Error)); 337 + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 338 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]")); 339 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 340 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", element); 335 341 336 342 consoleErrorSpy.mockRestore(); 337 343 });
+6 -6
lib/test/integration/global-state.test.ts
··· 449 449 }); 450 450 451 451 it("handles errors gracefully", () => { 452 - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); 453 - 452 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 454 453 document.body.innerHTML = ` 455 454 <div data-volt data-volt-init="nonExistentVariable.doSomething()"> 456 455 <p>Content</p> ··· 458 457 `; 459 458 460 459 charge(); 461 - 462 - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("Error in data-volt-init"), expect.any(Error)); 463 - 464 - consoleError.mockRestore(); 460 + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 461 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[binding]")); 462 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 463 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", expect.any(HTMLElement)); 464 + consoleErrorSpy.mockRestore(); 465 465 }); 466 466 }); 467 467
+9 -10
lib/test/integration/plugins.test.ts
··· 32 32 }); 33 33 34 34 it("warns when unknown binding is used without plugin", () => { 35 - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 35 + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 36 36 const element = document.createElement("div"); 37 37 element.dataset.voltUnknown = "value"; 38 38 39 39 mount(element, {}); 40 - 41 - expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-volt-unknown"); 42 - 43 - warnSpy.mockRestore(); 40 + expect(consoleWarnSpy).toHaveBeenCalledWith("Unknown binding: data-volt-unknown"); 41 + consoleWarnSpy.mockRestore(); 44 42 }); 45 43 46 44 it("provides working findSignal utility to plugin", () => { ··· 124 122 }); 125 123 126 124 it("handles plugin errors gracefully", () => { 127 - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 125 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 128 126 const badPlugin = vi.fn(() => { 129 127 throw new Error("Plugin error"); 130 128 }); ··· 135 133 element.dataset.voltBad = "value"; 136 134 137 135 mount(element, {}); 138 - 139 - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error in plugin \"bad\""), expect.any(Error)); 140 - 141 - errorSpy.mockRestore(); 136 + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); 137 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[plugin]")); 138 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); 139 + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", element); 140 + consoleErrorSpy.mockRestore(); 142 141 }); 143 142 144 143 it("supports reactive updates from plugins", () => {