diff --git a/ROADMAP.md b/ROADMAP.md --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,14 +19,24 @@ | v0.4.0 | ✓ | [Animation & Transitions](#animation--transitions) | | v0.5.0 | ✓ | [Navigation & History API Routing](#navigation--history-api-routing) | | | ✓ | [Refactor](#evaluator--binder-hardening) | +| v0.5.1 | ✓ | [Error Handling & Diagnostics](#error-handling--diagnostics) | +| v0.5.2 | | | +| v0.5.3 | | | +| v0.5.4 | | | +| v0.6.1 | | [Persistence & Offline](#persistence--offline) | +| v0.6.2 | | | +| v0.6.3 | | | +| v0.6.4 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | +| v0.6.5 | | | +| v0.6.6 | | | +| v0.6.7 | | [Streaming & Patch Engine](#streaming--patch-engine) | +| v0.6.8 | | | +| v0.6.9 | | | +| v0.6.10 | | | +| v0.7.0 | | | +| v0.8.0 | | Support `voltx-` & `vx-` attributes: recommend `vx-` | +| | | Switch to `data-voltx` | | | | Update demo to be a multi page application with routing plugin | -| v0.5.1 | | Support `voltx-` & `vx-` attributes: recommend `vx-` | -| v0.5.2 | | Switch to `data-voltx` | -| v0.5.3 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | -| v0.5.4 | | [Streaming & Patch Engine](#streaming--patch-engine) | -| v0.5.5 | | PWA Capabilities | -| v0.5.6 | | [Persistence & Offline](#persistence--offline) | -| | | | | v0.9.0 | | [Inspector & Developer Tools](#inspector--developer-tools) | | v1.0.0 | | [Stable Release](#stable-release) | @@ -102,18 +112,42 @@ ## To-Do +### Error Handling & Diagnostics + +**Goal**: Provide clear, actionable feedback when runtime or directive errors occur. +**Outcome**: VoltX.js surfaces developer-friendly diagnostics for expression evaluation, +directive parsing, and network operations, making it easier to debug apps without opaque stack traces. +**Deliverables**: + - v0.5.1 + ✓ Centralized error boundary system for directives and effects. + ✓ Sandbox error wrapping with contextual hints (directive name, expression, element). + ✓ `$volt.report(error, context)` API for plugin and app-level reporting. + - v0.5.2 + - Visual in-DOM error overlays for development mode. + - Enhanced console messages with source map trace and directive path. + - Differentiated error levels: warn, error, fatal. + - v0.5.3 + - Runtime health monitor tracking evaluation and subscription failures. + - v0.5.4 + - Documentation: "Understanding VoltX Errors" guide. + - Configurable global error policy (silent, overlay, throw). + ### Streaming & Patch Engine **Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching. **Outcome:** VoltX.js can receive and apply live updates from the server **Deliverables:** - - Server-Sent Events (SSE) integration - - `data-volt-flow` attribute for SSE endpoints - - Signal patching from backend (`data-signals-*` merge system) - - Backend action system with `$$spark()` syntax - - JSON Patch parser and DOM morphing engine - - WebSocket as alternative to SSE - - `data-volt-ignore-morph` for selective patch exclusion + - v0.5.7 + - Server-Sent Events (SSE) integration + - `data-volt-flow` attribute for SSE endpoints + - v0.5.8 + - Signal patching from backend (`data-signals-*` merge system) + - Backend action system with `$$spark()` syntax + - v0.5.9 + - JSON Patch parser and DOM morphing engine + - `data-volt-ignore-morph` for selective patch exclusion + - v0.5.10 + - WebSocket as alternative to SSE ### Persistence & Offline @@ -122,43 +156,55 @@ **Deliverables:** - ✓ Persistent signals (localStorage, sessionStorage, indexedDb) - ✓ Storage plugin (`data-volt-persist`) - - Storage modifiers on signals: - - `.local` modifier for localStorage persistence - - `.session` modifier for sessionStorage persistence - - `.ifmissing` modifier for conditional initialization - - Offline queue for deferred stream events and HTTP requests - - Sync strategy API (merge, overwrite, patch) for conflict resolution - - Service Worker integration for offline-first apps - - Background sync for deferred requests - - Cache invalidation strategies - - Cross-tab synchronization via `BroadcastChannel` + - v0.5.1 + - Storage modifiers on signals: + - `.local` modifier for localStorage persistence + - `.session` modifier for sessionStorage persistence + - `.ifmissing` modifier for conditional initialization + - v0.5.2 + - Sync strategy API (merge, overwrite, patch) for conflict resolution + - Cache invalidation strategies + - v0.5.3 + - Offline queue for deferred stream events and HTTP requests + - Service Worker integration for offline-first apps + - Background sync for deferred requests + - Cross-tab synchronization via `BroadcastChannel` ### Background Requests & Reactive Polling **Goal:** Enable declarative background data fetching and periodic updates within the VoltX.js runtime. **Outcome:** VoltX.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions. **Deliverables:** - - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) - - `data-volt-fetch` attribute for declarative background requests - - Configurable polling intervals, delays, and signal-based triggers - - Automatic cancellation of requests when elements are unmounted - - Conditional execution tied to reactive signals - - Integration hooks for loading and pending states - - Background task scheduler with priority management + - v0.5.4 + - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) + - v0.5.5 + - `data-volt-fetch` attribute for declarative background requests + - Configurable polling intervals, delays, and signal-based triggers + - Automatic cancellation of requests when elements are unmounted + - Conditional execution tied to reactive signals + - Integration hooks for loading and pending states + - v0.5.6 + - Background task scheduler with priority management ### Inspector & Developer Tools **Goal:** Improve developer experience and runtime introspection. **Outcome:** First-class developer ergonomics; VoltX.js is enjoyable to debug and extend. **Deliverables:** - - Developer overlay for inspecting signals, subscriptions, and effects - - Dev logging toggle (`Volt.debug = true`) - - Browser console integration (`window.$volt.inspect()`) - - Signal dependency graph visualization (graph data structure implemented in [proxy](#proxy-based-reactivity-enhancements) milestone) - - Performance profiling tools - - Request/response debugging (HTTP actions, SSE streams) - - Time-travel debugging for signal history - - Browser DevTools extension + - v0.9.1 + - Developer overlay for inspecting signals, subscriptions, and effects + - Time-travel debugging for signal history + - v0.9.2 + - Signal dependency graph visualization (graph data structure implemented in [proxy](#proxy-based-reactivity-enhancements) milestone) + - v0.9.3 + - Browser console integration (`window.$volt.inspect()`) + - Dev logging toggle (`Volt.debug = true`) + - v0.9.4 + - Request/response debugging (HTTP actions, SSE streams) + - v0.9.5 + - Performance profiling tools + - v0.9.6 to v0.9.10 + - Browser DevTools extension ### Stable Release diff --git a/lib/src/index.ts b/lib/src/index.ts --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -7,6 +7,8 @@ export { asyncEffect } from "$core/async-effect"; export { mount } from "$core/binder"; export { charge } from "$core/charge"; +export { clearErrorHandlers, onError, report } from "$core/error"; +export type { VoltError } from "$core/error"; export { parseHttpConfig, request, serializeForm, serializeFormToJSON, swap } from "$core/http"; export { clearAllGlobalHooks, @@ -53,7 +55,15 @@ supportsViewTransitions, withViewTransition, } from "$core/view-transitions"; -export { goBack, goForward, getRouterMode, initNavigationListener, navigate, redirect, setRouterMode } from "$plugins/navigate"; +export { + getRouterMode, + goBack, + goForward, + initNavigationListener, + navigate, + redirect, + setRouterMode, +} from "$plugins/navigate"; export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; export { scrollPlugin } from "$plugins/scroll"; export { @@ -74,6 +84,9 @@ ChargedRoot, ChargeResult, ComputedSignal, + ErrorContext, + ErrorHandler, + ErrorSource, GlobalHookName, GlobalStore, HydrateOptions, diff --git a/lib/src/core/async-effect.ts b/lib/src/core/async-effect.ts --- a/lib/src/core/async-effect.ts +++ b/lib/src/core/async-effect.ts @@ -4,6 +4,7 @@ import type { Optional, Timer } from "$types/helpers"; import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "$types/volt"; +import { report } from "./error"; /** * Creates an async side effect that runs when dependencies change. @@ -73,7 +74,7 @@ try { cleanup(); } catch (error) { - console.error("Error in async effect cleanup:", error); + report(error as Error, { source: "effect" }); } cleanup = undefined; } @@ -115,7 +116,7 @@ await executeEffect(currentExecutionId); } } else { - console.error("Error in async effect:", err); + report(err as Error, { source: "effect" }); if (onError) { const retry = () => { @@ -198,7 +199,7 @@ try { cleanup(); } catch (error) { - console.error("Error during async effect unmount:", error); + report(error as Error, { source: "effect" }); } cleanup = undefined; } diff --git a/lib/src/core/binder.ts b/lib/src/core/binder.ts --- a/lib/src/core/binder.ts +++ b/lib/src/core/binder.ts @@ -16,6 +16,7 @@ } from "$types/volt"; import { BOOLEAN_ATTRS } from "./constants"; import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; +import { report } from "./error"; import { evaluate } from "./evaluator"; import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; @@ -132,7 +133,7 @@ try { cleanup(); } catch (error) { - console.error("Error during unmount:", error); + report(error as Error, { source: "binding", element: root as HTMLElement }); } } @@ -145,7 +146,12 @@ try { plugin(pluginCtx, val); } catch (error) { - console.error(`Error in plugin "${base}":`, error); + report(error as Error, { + source: "plugin", + element: ctx.element as HTMLElement, + directive: `data-volt-${base}`, + pluginName: base, + }); } } @@ -227,14 +233,12 @@ break; } default: { - // Check directive registry first (for HTTP and other optional directives) const directiveHandler = directiveRegistry.get(baseName); if (directiveHandler) { directiveHandler(ctx, value, modifiers); return; } - // Then check plugin registry const plugin = getPlugin(baseName); if (plugin) { execPlugin(plugin, ctx, value, baseName); @@ -374,7 +378,12 @@ try { element.style.setProperty(cssKey, String(val)); } catch (error) { - console.warn(`[Volt] Failed to set style property "${cssKey}":`, error); + report(error as Error, { + source: "binding", + element: element, + directive: "data-volt-style", + expression: expr, + }); } } } @@ -458,7 +467,12 @@ result(event); } } catch (error) { - console.error(`Error in event handler (${eventName}):`, error); + report(error as Error, { + source: "binding", + element: ctx.element as HTMLElement, + directive: `data-volt-on-${eventName}`, + expression: expr, + }); } }; @@ -598,7 +612,12 @@ function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void { const result = findModelSignal(context.scope, signalPath); if (!result) { - console.error(`Signal "${signalPath}" not found for data-volt-model`); + report(new Error(`Signal "${signalPath}" not found`), { + source: "binding", + element: context.element as HTMLElement, + directive: "data-volt-model", + expression: signalPath, + }); return; } @@ -766,7 +785,12 @@ evaluate(stmt, ctx.scope, { unwrapSignals: false }); } } catch (error) { - console.error("Error in data-volt-init:", error); + report(error as Error, { + source: "binding", + element: ctx.element as HTMLElement, + directive: "data-volt-init", + expression: expr, + }); } } @@ -791,7 +815,12 @@ function bindFor(ctx: BindingContext, expr: string): void { const parsed = parseForExpr(expr); if (!parsed) { - console.error(`Invalid data-volt-for expression: "${expr}"`); + report(new Error(`Invalid data-volt-for expression: "${expr}"`), { + source: "binding", + element: ctx.element as HTMLElement, + directive: "data-volt-for", + expression: expr, + }); return; } @@ -800,7 +829,12 @@ const parent = templ.parentElement; if (!parent) { - console.error("data-volt-for element must have a parent"); + report(new Error("data-volt-for element must have a parent"), { + source: "binding", + element: ctx.element as HTMLElement, + directive: "data-volt-for", + expression: expr, + }); return; } @@ -863,7 +897,12 @@ const parent = ifTempl.parentElement; if (!parent) { - console.error("data-volt-if element must have a parent"); + report(new Error("data-volt-if element must have a parent"), { + source: "binding", + element: ctx.element as HTMLElement, + directive: "data-volt-if", + expression: expr, + }); return; } @@ -1034,7 +1073,7 @@ try { cb(); } catch (error) { - console.error("Error in plugin onMount hook:", error); + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onMount" }); } }, onUnmount: (cb: () => void) => { @@ -1045,7 +1084,7 @@ try { cb(); } catch (error) { - console.error("Error in plugin beforeBinding hook:", error); + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "beforeBinding" }); } }, afterBinding: (cb: () => void) => { @@ -1054,7 +1093,7 @@ try { cb(); } catch (error) { - console.error("Error in plugin afterBinding hook:", error); + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "afterBinding" }); } }); }, @@ -1065,7 +1104,7 @@ try { cb(); } catch (error) { - console.error("Error in plugin onUnmount hook:", error); + report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onUnmount" }); } } }); diff --git a/lib/src/core/charge.ts b/lib/src/core/charge.ts --- a/lib/src/core/charge.ts +++ b/lib/src/core/charge.ts @@ -6,6 +6,7 @@ import type { ChargedRoot, ChargeResult, Scope } from "$types/volt"; import { mount } from "./binder"; +import { report } from "./error"; import { evaluate } from "./evaluator"; import { getComputedAttributes, isNil } from "./shared"; import { computed, signal } from "./signal"; @@ -13,6 +14,7 @@ /** * Discover and mount all Volt roots in the document. + * * Parses data-volt-state for initial state and data-volt-computed for derived values. * Also parses declarative global store from script[data-volt-store] elements. * @@ -54,7 +56,7 @@ chargedRoots.push({ element, scope, cleanup }); } catch (error) { - console.error("Error charging Volt root:", element, error); + report(error as Error, { source: "charge", element: element as HTMLElement }); } } @@ -65,7 +67,7 @@ try { root.cleanup(); } catch (error) { - console.error("Error cleaning up Volt root:", root.element, error); + report(error as Error, { source: "charge", element: root.element as HTMLElement }); } } }, @@ -84,15 +86,24 @@ const stateData = JSON.parse(stateAttr); if (typeof stateData !== "object" || isNil(stateData) || Array.isArray(stateData)) { - console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); + report(new Error(`data-volt-state must be a JSON object, got ${typeof stateData}`), { + source: "charge", + element: el as HTMLElement, + directive: "data-volt-state", + expression: stateAttr, + }); } else { for (const [key, value] of Object.entries(stateData)) { scope[key] = signal(value); } } } catch (error) { - console.error("Failed to parse data-volt-state JSON:", stateAttr, error); - console.error("Element:", el); + report(error as Error, { + source: "charge", + element: el as HTMLElement, + directive: "data-volt-state", + expression: stateAttr, + }); } } @@ -101,7 +112,12 @@ try { scope[name] = computed(() => evaluate(expression, scope)); } catch (error) { - console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); + report(error as Error, { + source: "charge", + element: el as HTMLElement, + directive: `data-volt-computed:${name}`, + expression: expression, + }); } } @@ -125,14 +141,17 @@ const data = JSON.parse(content); if (typeof data !== "object" || isNil(data) || Array.isArray(data)) { - console.error("data-volt-store script must contain a JSON object, got:", typeof data); + report(new Error(`data-volt-store script must contain a JSON object, got: ${typeof data}`), { + source: "charge", + element: script as HTMLElement, + directive: "data-volt-store", + }); continue; } registerStore(data); } catch (error) { - console.error("Failed to parse data-volt-store script:", error); - console.error("Script element:", script); + report(error as Error, { source: "charge", element: script as HTMLElement, directive: "data-volt-store" }); } } } diff --git a/lib/src/core/error.ts b/lib/src/core/error.ts new file mode 100644 --- /dev/null +++ b/lib/src/core/error.ts @@ -0,0 +1,246 @@ +/** + * Core error handling and reporting system for VoltX.js + * + * Provides centralized error boundary with rich contextual information + * for debugging directives, expressions, effects, and HTTP operations. + * + * @module core/error + */ +import type { ErrorContext, ErrorHandler, ErrorSource } from "$types/volt"; + +/** + * Enhanced error class with VoltX context + * + * Wraps original errors with rich debugging information including + * source, element, directive, and expression details. + */ +export class VoltError extends Error { + /** Error source category */ + public readonly source: ErrorSource; + /** DOM element where error occurred */ + public readonly element?: HTMLElement; + /** Directive name */ + public readonly directive?: string; + /** Expression that failed */ + public readonly expression?: string; + /** Original error */ + public readonly cause: Error; + /** When error occurred */ + public readonly timestamp: number; + /** Full error context */ + public readonly context: ErrorContext; + /** Whether propagation was stopped */ + private _stopped: boolean = false; + + constructor(cause: Error, context: ErrorContext) { + const message = VoltError.buildMessage(cause, context); + super(message); + this.name = "VoltError"; + this.cause = cause; + this.source = context.source; + this.element = context.element; + this.directive = context.directive; + this.expression = context.expression; + this.context = context; + this.timestamp = Date.now(); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this); + } + } + + /** + * Stop propagation to subsequent error handlers + */ + public stopPropagation(): void { + this._stopped = true; + } + + /** + * Check if propagation was stopped + */ + public get stopped(): boolean { + return this._stopped; + } + + private static buildMessage(cause: Error, context: ErrorContext): string { + const parts: string[] = []; + + parts.push(`[${context.source}] ${cause.message}`); + + if (context.directive) { + parts.push(`Directive: ${context.directive}`); + } + + if (context.expression) { + const truncated = context.expression.length > 100 ? `${context.expression.slice(0, 100)}...` : context.expression; + parts.push(`Expression: ${truncated}`); + } + + if (context.pluginName) { + parts.push(`Plugin: ${context.pluginName}`); + } + + if (context.httpMethod && context.httpUrl) { + parts.push(`HTTP: ${context.httpMethod} ${context.httpUrl}`); + if (context.httpStatus) { + parts.push(`Status: ${context.httpStatus}`); + } + } + + if (context.hookName) { + parts.push(`Hook: ${context.hookName}`); + } + + if (context.element) { + const tag = context.element.tagName.toLowerCase(); + const id = context.element.id ? `#${context.element.id}` : ""; + const cls = context.element.className ? `.${context.element.className.split(" ").join(".")}` : ""; + parts.push(`Element: <${tag}${id}${cls}>`); + } + + return parts.join(" | "); + } + + /** + * Serialize error for logging/reporting + */ + public toJSON(): Record { + return { + name: this.name, + message: this.message, + source: this.source, + directive: this.directive, + expression: this.expression, + timestamp: this.timestamp, + context: this.context, + cause: { name: this.cause.name, message: this.cause.message, stack: this.cause.stack }, + stack: this.stack, + }; + } +} + +/** + * Global error handler registry + */ +let errorHandlers: ErrorHandler[] = []; + +/** + * Register an error handler + * + * Multiple handlers can be registered and will be called in registration order. + * Handlers can call `error.stopPropagation()` to prevent subsequent handlers + * from being called. + * + * @param handler - Error handler function + * @returns Cleanup function to unregister the handler + * + * @example + * ```ts + * const cleanup = onError((error) => { + * console.log('Error source:', error.source); + * console.log('Element:', error.element); + * console.log('Expression:', error.expression); + * + * // Stop other handlers from running + * if (error.source === "http") { + * error.stopPropagation(); + * } + * }); + * + * // Later: cleanup() + * ``` + */ +export function onError(handler: ErrorHandler): () => void { + errorHandlers.push(handler); + return () => { + errorHandlers = errorHandlers.filter((h) => h !== handler); + }; +} + +/** + * Clear all registered error handlers + * + * Useful for testing or when you want to reset error handling state. + * + * @example + * ```ts + * clearErrorHandlers(); + * ``` + */ +export function clearErrorHandlers(): void { + errorHandlers = []; +} + +/** + * Report an error through the centralized error boundary + * + * This function is used both internally by VoltX and externally by user code. + * All errors flow through this unified system. + * + * If no error handlers are registered, errors are logged to console as fallback. + * Once handlers are registered, console logging is disabled. + * + * @param error - Error to report (can be Error, unknown, or string) + * @param context - Error context with source and additional details + * + * @example + * ```ts + * // Internal usage (by VoltX) + * try { + * evaluate(expression, scope); + * } catch (err) { + * report(err, { + * source: ErrorSource.Evaluator, + * element: ctx.element, + * directive: 'data-volt-text', + * expression: expression + * }); + * } + * + * // External usage (by plugins/apps) + * try { + * myCustomLogic(); + * } catch (err) { + * report(err, { + * source: ErrorSource.User, + * customContext: 'My feature failed' + * }); + * } + * ``` + */ +export function report(error: unknown, context: ErrorContext): void { + const errorObj = error instanceof Error ? error : new Error(String(error)); + const voltError = new VoltError(errorObj, context); + + if (errorHandlers.length === 0) { + console.error(voltError.message); + console.error("Caused by:", voltError.cause); + if (voltError.element) { + console.error("Element:", voltError.element); + } + return; + } + + for (const handler of errorHandlers) { + try { + handler(voltError); + if (voltError.stopped) { + break; + } + } catch (handlerError) { + console.error("Error in error handler:", handlerError); + } + } +} + +/** + * Get count of registered error handlers + * + * Useful for testing and debugging error handling setup. + * + * @returns Number of registered error handlers + */ +export function getErrorHandlerCount(): number { + return errorHandlers.length; +} diff --git a/lib/src/core/http.ts b/lib/src/core/http.ts --- a/lib/src/core/http.ts +++ b/lib/src/core/http.ts @@ -17,6 +17,7 @@ SwapStrategy, } from "$types/volt"; import { registerDirective } from "./binder"; +import { report } from "./error"; import { evaluate } from "./evaluator"; import { sleep } from "./shared"; @@ -228,7 +229,10 @@ break; } default: { - console.error(`Unknown swap strategy: ${strategy as string}`); + report(new Error(`Unknown swap strategy: ${strategy as string}`), { + source: "http", + element: target as HTMLElement, + }); } } } @@ -308,7 +312,12 @@ headers = headersValue as Record; } } catch (error) { - console.error("Failed to parse data-volt-headers:", error); + report(error as Error, { + source: "http", + element: el as HTMLElement, + directive: "data-volt-headers", + expression: dataset.voltHeaders, + }); } } @@ -499,7 +508,11 @@ const target = document.querySelector(targetConf); if (!target) { - console.warn(`Target element not found: ${targetConf}`); + report(new Error(`Target element not found: ${targetConf}`), { + source: "http", + element: defaultEl as HTMLElement, + directive: "data-volt-target", + }); return undefined; } @@ -630,7 +643,7 @@ const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); setErrorState(target, errorMessage, conf.indicator); - console.error("HTTP request failed:", lastError); + report(lastError as Error, { source: "http", element: el as HTMLElement, httpMethod: method, httpUrl: url }); } export function bindGet(ctx: BindingContext, url: string): void { diff --git a/lib/src/core/lifecycle.ts b/lib/src/core/lifecycle.ts --- a/lib/src/core/lifecycle.ts +++ b/lib/src/core/lifecycle.ts @@ -4,6 +4,7 @@ */ import type { ElementLifecycleState, GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt"; +import { report } from "./error"; /** * Global lifecycle hooks registry @@ -124,7 +125,7 @@ (callback as UnmountHookCallback)(root); } } catch (error) { - console.error(`Error in global ${hookName} hook:`, error); + report(error as Error, { source: "lifecycle", element: root as HTMLElement, hookName: hookName }); } } } @@ -181,7 +182,7 @@ try { callback(); } catch (error) { - console.error("Error in element onMount hook:", error); + report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onMount" }); } } } @@ -205,7 +206,7 @@ try { callback(); } catch (error) { - console.error("Error in element onUnmount hook:", error); + report(error as Error, { source: "lifecycle", element: el as HTMLElement, hookName: "onUnmount" }); } } diff --git a/lib/src/core/signal.ts b/lib/src/core/signal.ts --- a/lib/src/core/signal.ts +++ b/lib/src/core/signal.ts @@ -1,4 +1,5 @@ import type { ComputedSignal, Signal } from "$types/volt"; +import { report } from "./error"; import { recordDep, startTracking, stopTracking } from "./tracker"; /** @@ -25,7 +26,7 @@ try { callback(value); } catch (error) { - console.error("Error in signal subscriber:", error); + report(error as Error, { source: "effect" }); } } }; @@ -87,7 +88,7 @@ try { cb(value); } catch (error) { - console.error("Error in computed subscriber:", error); + report(error as Error, { source: "effect" }); } } }; @@ -116,7 +117,7 @@ shouldNotify = subs.size > 0; } } catch (error) { - console.error("Error in computed:", error); + report(error as Error, { source: "effect" }); throw error; } finally { const deps = stopTracking(); @@ -196,7 +197,7 @@ try { cleanup(); } catch (error) { - console.error("Error in effect cleanup:", error); + report(error as Error, { source: "effect" }); } cleanup = undefined; } @@ -205,7 +206,7 @@ try { cleanup = cb(); } catch (error) { - console.error("Error in effect:", error); + report(error as Error, { source: "effect" }); } finally { const deps = stopTracking(); @@ -225,7 +226,7 @@ try { cleanup(); } catch (error) { - console.error("Error in effect cleanup:", error); + report(error as Error, { source: "effect" }); } } @@ -233,7 +234,7 @@ try { unsubscribe(); } catch (error) { - console.error("Error unsubscribing effect:", error); + report(error as Error, { source: "effect" }); } } }; diff --git a/lib/src/types/volt.d.ts b/lib/src/types/volt.d.ts --- a/lib/src/types/volt.d.ts +++ b/lib/src/types/volt.d.ts @@ -552,3 +552,37 @@ */ forceFallback?: boolean; }; + +/** + * Error source categories for identifying where errors occurred + */ +export type ErrorSource = "evaluator" | "binding" | "effect" | "http" | "plugin" | "lifecycle" | "charge" | "user"; + +/** + * Context information for error reporting + */ +export type ErrorContext = { + /** Error source category */ + source: ErrorSource; + /** DOM element where error occurred */ + element?: HTMLElement; + /** Directive name (e.g., "data-volt-text", "data-volt-on-click") */ + directive?: string; + /** Expression that failed */ + expression?: string; + /** Plugin name (for plugin errors) */ + pluginName?: string; + /** HTTP method and URL (for HTTP errors) */ + httpMethod?: string; + httpUrl?: string; + httpStatus?: number; + /** Lifecycle hook name (for lifecycle errors) */ + hookName?: string; + /** Additional custom context */ + [key: string]: unknown; +}; + +/** + * Error handler function signature + */ +export type ErrorHandler = (error: VoltError) => void; diff --git a/lib/test/core/async-effect.test.ts b/lib/test/core/async-effect.test.ts --- a/lib/test/core/async-effect.test.ts +++ b/lib/test/core/async-effect.test.ts @@ -385,7 +385,9 @@ await vi.runAllTimersAsync(); - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in async effect:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[effect]")); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); consoleErrorSpy.mockRestore(); }); diff --git a/lib/test/core/error.test.ts b/lib/test/core/error.test.ts new file mode 100644 --- /dev/null +++ b/lib/test/core/error.test.ts @@ -0,0 +1,300 @@ +import { clearErrorHandlers, getErrorHandlerCount, onError, report, VoltError } from "$core/error"; +import type { ErrorContext, ErrorSource } from "$types/volt"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("VoltError", () => { + it("creates error with basic context", () => { + const cause = new Error("Test error"); + const context: ErrorContext = { source: "binding" }; + + const voltError = new VoltError(cause, context); + + expect(voltError).toBeInstanceOf(Error); + expect(voltError).toBeInstanceOf(VoltError); + expect(voltError.name).toBe("VoltError"); + expect(voltError.source).toBe("binding"); + expect(voltError.cause).toBe(cause); + expect(voltError.stopped).toBe(false); + }); + + it("includes directive and expression in context", () => { + const cause = new Error("Evaluation failed"); + const context: ErrorContext = { source: "evaluator", directive: "data-volt-text", expression: "count * 2" }; + + const voltError = new VoltError(cause, context); + + expect(voltError.directive).toBe("data-volt-text"); + expect(voltError.expression).toBe("count * 2"); + expect(voltError.message).toContain("[evaluator]"); + expect(voltError.message).toContain("Directive: data-volt-text"); + expect(voltError.message).toContain("Expression: count * 2"); + }); + + it("includes element information in message", () => { + const div = document.createElement("div"); + div.id = "test"; + div.className = "foo bar"; + + const cause = new Error("DOM error"); + const context: ErrorContext = { source: "binding", element: div }; + + const voltError = new VoltError(cause, context); + + expect(voltError.element).toBe(div); + expect(voltError.message).toContain("Element: "); + }); + + it("includes HTTP context in message", () => { + const cause = new Error("Request failed"); + const context: ErrorContext = { source: "http", httpMethod: "POST", httpUrl: "/api/users", httpStatus: 500 }; + + const voltError = new VoltError(cause, context); + + expect(voltError.message).toContain("HTTP: POST /api/users"); + expect(voltError.message).toContain("Status: 500"); + }); + + it("includes plugin name in message", () => { + const cause = new Error("Plugin failed"); + const context: ErrorContext = { source: "plugin", pluginName: "persist" }; + + const voltError = new VoltError(cause, context); + + expect(voltError.message).toContain("Plugin: persist"); + }); + + it("includes lifecycle hook name in message", () => { + const cause = new Error("Hook failed"); + const context: ErrorContext = { source: "lifecycle", hookName: "onMount" }; + + const voltError = new VoltError(cause, context); + + expect(voltError.message).toContain("Hook: onMount"); + }); + + it("stopPropagation prevents handler chain", () => { + const cause = new Error("Test"); + const context: ErrorContext = { source: "binding" }; + + const voltError = new VoltError(cause, context); + + expect(voltError.stopped).toBe(false); + voltError.stopPropagation(); + expect(voltError.stopped).toBe(true); + }); + + it("serializes to JSON", () => { + const cause = new Error("Test error"); + const context: ErrorContext = { source: "effect", directive: "data-volt-on-click", expression: "count++" }; + + const voltError = new VoltError(cause, context); + const json = voltError.toJSON(); + + expect(json.name).toBe("VoltError"); + expect(json.source).toBe("effect"); + expect(json.directive).toBe("data-volt-on-click"); + expect(json.expression).toBe("count++"); + expect(json.cause).toEqual({ name: "Error", message: "Test error", stack: cause.stack }); + }); + + it("truncates long expressions in message", () => { + const longExpr = "a".repeat(150); + const cause = new Error("Test"); + const context: ErrorContext = { source: "evaluator", expression: longExpr }; + + const voltError = new VoltError(cause, context); + + expect(voltError.message).toContain("Expression: " + "a".repeat(100) + "..."); + expect(voltError.message).not.toContain("a".repeat(101)); + }); +}); + +describe("Error Handler Registration", () => { + beforeEach(() => { + clearErrorHandlers(); + }); + + afterEach(() => { + clearErrorHandlers(); + }); + + it("registers error handler", () => { + expect(getErrorHandlerCount()).toBe(0); + + const handler = vi.fn(); + onError(handler); + + expect(getErrorHandlerCount()).toBe(1); + }); + + it("returns cleanup function", () => { + const handler = vi.fn(); + const cleanup = onError(handler); + + expect(getErrorHandlerCount()).toBe(1); + + cleanup(); + + expect(getErrorHandlerCount()).toBe(0); + }); + + it("registers multiple handlers", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + onError(handler1); + onError(handler2); + + expect(getErrorHandlerCount()).toBe(2); + }); + + it("clears all handlers", () => { + onError(vi.fn()); + onError(vi.fn()); + onError(vi.fn()); + + expect(getErrorHandlerCount()).toBe(3); + + clearErrorHandlers(); + + expect(getErrorHandlerCount()).toBe(0); + }); +}); + +describe("Error Reporting", () => { + beforeEach(() => { + clearErrorHandlers(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + clearErrorHandlers(); + vi.restoreAllMocks(); + }); + + it("calls registered handler with VoltError", () => { + const handler = vi.fn(); + onError(handler); + + const error = new Error("Test"); + const context: ErrorContext = { source: "binding" }; + + report(error, context); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.any(VoltError)); + + const voltError = handler.mock.calls[0][0]; + expect(voltError.cause).toBe(error); + expect(voltError.source).toBe("binding"); + }); + + it("calls multiple handlers in order", () => { + const callOrder: number[] = []; + + const handler1 = vi.fn(() => callOrder.push(1)); + const handler2 = vi.fn(() => callOrder.push(2)); + const handler3 = vi.fn(() => callOrder.push(3)); + + onError(handler1); + onError(handler2); + onError(handler3); + + report(new Error("Test"), { source: "effect" }); + + expect(callOrder).toEqual([1, 2, 3]); + }); + + it("stops propagation when stopPropagation is called", () => { + const handler1 = vi.fn((error: VoltError) => { + error.stopPropagation(); + }); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + onError(handler1); + onError(handler2); + onError(handler3); + + report(new Error("Test"), { source: "effect" }); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).not.toHaveBeenCalled(); + expect(handler3).not.toHaveBeenCalled(); + }); + + it("falls back to console.error when no handlers registered", () => { + const error = new Error("Test error"); + const context: ErrorContext = { source: "http", httpMethod: "GET", httpUrl: "/api/data" }; + + report(error, context); + + expect(console.error).toHaveBeenCalledTimes(2); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("[http]")); + expect(console.error).toHaveBeenCalledWith("Caused by:", error); + }); + + it("converts non-Error values to Error", () => { + const handler = vi.fn(); + onError(handler); + + report("string error", { source: "user" }); + + expect(handler).toHaveBeenCalledTimes(1); + const voltError: VoltError = handler.mock.calls[0][0]; + expect(voltError.cause).toBeInstanceOf(Error); + expect(voltError.cause.message).toBe("string error"); + }); + + it("catches errors in error handlers", () => { + const handler1 = vi.fn(() => { + throw new Error("Handler error"); + }); + const handler2 = vi.fn(); + + onError(handler1); + onError(handler2); + + report(new Error("Test"), { source: "effect" }); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith("Error in error handler:", expect.any(Error)); + }); + + it("includes element in console fallback", () => { + const div = document.createElement("div"); + div.id = "test-element"; + + report(new Error("Test"), { source: "binding", element: div }); + + expect(console.error).toHaveBeenCalledWith("Element:", div); + }); + + it("handles all error sources", () => { + const handler = vi.fn(); + onError(handler); + + const sources: Array = [ + "evaluator", + "binding", + "effect", + "http", + "plugin", + "lifecycle", + "charge", + "user", + ]; + + for (const source of sources) { + report(new Error(`Test ${source}`), { source }); + } + + expect(handler).toHaveBeenCalledTimes(sources.length); + + for (const [i, source] of sources.entries()) { + const voltError: VoltError = handler.mock.calls[i][0]; + expect(voltError.source).toBe(source); + } + }); +}); diff --git a/lib/test/core/lifecycle.test.ts b/lib/test/core/lifecycle.test.ts --- a/lib/test/core/lifecycle.test.ts +++ b/lib/test/core/lifecycle.test.ts @@ -245,7 +245,10 @@ mount(root, {}); }).not.toThrow(); - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in global beforeMount hook:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]")); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", root); consoleErrorSpy.mockRestore(); }); @@ -331,7 +334,10 @@ }); notifyElementMounted(element); - expect(consoleErrorSpy).toHaveBeenCalledWith("Error in element onMount hook:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[lifecycle]")); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", element); consoleErrorSpy.mockRestore(); }); diff --git a/lib/test/integration/global-state.test.ts b/lib/test/integration/global-state.test.ts --- a/lib/test/integration/global-state.test.ts +++ b/lib/test/integration/global-state.test.ts @@ -449,8 +449,7 @@ }); it("handles errors gracefully", () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); document.body.innerHTML = `

Content

@@ -458,10 +457,11 @@ `; charge(); - - expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("Error in data-volt-init"), expect.any(Error)); - - consoleError.mockRestore(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[binding]")); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", expect.any(HTMLElement)); + consoleErrorSpy.mockRestore(); }); }); diff --git a/lib/test/integration/plugins.test.ts b/lib/test/integration/plugins.test.ts --- a/lib/test/integration/plugins.test.ts +++ b/lib/test/integration/plugins.test.ts @@ -32,15 +32,13 @@ }); it("warns when unknown binding is used without plugin", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const element = document.createElement("div"); element.dataset.voltUnknown = "value"; mount(element, {}); - - expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-volt-unknown"); - - warnSpy.mockRestore(); + expect(consoleWarnSpy).toHaveBeenCalledWith("Unknown binding: data-volt-unknown"); + consoleWarnSpy.mockRestore(); }); it("provides working findSignal utility to plugin", () => { @@ -124,7 +122,7 @@ }); it("handles plugin errors gracefully", () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const badPlugin = vi.fn(() => { throw new Error("Plugin error"); }); @@ -135,10 +133,11 @@ element.dataset.voltBad = "value"; mount(element, {}); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error in plugin \"bad\""), expect.any(Error)); - - errorSpy.mockRestore(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(3); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(1, expect.stringContaining("[plugin]")); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(2, "Caused by:", expect.any(Error)); + expect(consoleErrorSpy).toHaveBeenNthCalledWith(3, "Element:", element); + consoleErrorSpy.mockRestore(); }); it("supports reactive updates from plugins", () => {