Error handling¶
JoltUI routes errors from every part of an application to one place: the error handler
classes registered on the App. The design follows the Java pattern of @ControllerAdvice
classes with @ExceptionHandler methods: handlers are declared by annotating methods with the
error types they handle, and the framework picks the right one.
Writing an error handler¶
import {
AuthenticationError,
ErrorHandler,
RouteNotFoundError,
handles,
} from "joltui";
import type MyApp from "./app.js";
import NotFoundPage from "./errorPages/notFoundPage.js";
import { ValidationError } from "./errors.js";
export default class MyErrorHandler extends ErrorHandler<MyApp> {
@handles(AuthenticationError)
async signIn(error: AuthenticationError): Promise<void> {
await this.app.router.redirect(`/login?target=${encodeURIComponent(error.path)}`);
}
@handles(RouteNotFoundError)
async notFound(error: RouteNotFoundError): Promise<void> {
await this.app.router.showErrorPage(NotFoundPage, {
error,
title: "Page not found",
});
}
@handles(ValidationError)
showValidation(error: ValidationError): void {
this.app.setData("formErrors", error.fieldErrors);
}
}
Register the class on the application:
export default class MyApp extends App<MyApp> {
override readonly errorHandlers:
readonly ErrorHandlerClass<MyApp>[] = [MyErrorHandler];
}
The App creates one instance of each class during init() and passes itself to the constructor,
so handler methods can use this.app. A method may handle several types:
@handles(NetworkError, InternalServerError).
Where errors come from¶
You rarely call the error handlers yourself. JoltUI calls them for:
| Source | Error passed to the handlers |
|---|---|
| An event handler throws | MethodExecutionError (cause: your error) |
| A lifecycle hook throws during initialization | ElementInitializationError → MethodExecutionError → your error |
markup() throws during initialization |
ElementInitializationError → RenderError → your error |
| A route does not exist | RouteNotFoundError |
| Authentication / authorization fails | AuthenticationError / AuthorizationError |
| A link-click navigation throws | RouteFailedError (cause: the failure) |
| A server component cannot be loaded | see Server components |
To report an error from your own code, pass it to the App:
handleError throws if no handler accepts the error. handleErrorOnce remembers each error
object and handles it only once; JoltUI uses it so that an initialization failure that propagates
through several parent components is reported a single time.
Errors and their causes¶
JoltUI wraps errors to add context: which method of which component failed. The original error
is kept in the standard cause property, just like Java's exception chaining:
class ValidationError extends Error {
constructor(readonly fieldErrors: Record<string, string>) {
super("Validation failed");
this.name = "ValidationError";
}
}
// in a component
save(): void {
throw new ValidationError({ title: "Title is required" });
}
The handlers receive a MethodExecutionError whose cause is the ValidationError. Handler
resolution looks through the chain, so @handles(ValidationError) is called with the
ValidationError itself.
How a handler is chosen¶
For each error, the App asks its handler classes in registration order. The first class with a matching method handles the error, and the search stops. Within one class:
- Take the error.
- Exact type match: look for a method registered for the error's own class; if there is none,
for its parent class, grandparent, and so on up to
Error. instanceofmatch: otherwise, test the registered types withinstanceofin discovery order. This includes types with a customSymbol.hasInstance, which lets you handle errors from libraries such as Zod (@handles(ZodError)) without JoltUI depending on them.- Follow the cause: otherwise, repeat with
error.cause.
Discovery order is subclass methods before base-class methods, declaration order within a class,
and argument order within @handles.
Keep catch-all handlers in a separate class
Step 2 walks up to Error. A @handles(Error) method therefore matches the outer wrapper,
such as MethodExecutionError, before the cause is ever inspected. Put specific handlers in
one class and a catch-all in a second class registered last:
If no class handles an error, it is rethrown and appears in the browser console as an uncaught error. Nothing is swallowed silently.
Inheritance between handler classes¶
- a decorated override in a subclass replaces the base method's registration;
- an undecorated override removes it (decorators are not inherited);
- unrelated inherited handlers stay active.
Two different effective methods in one class hierarchy cannot register the same error type; the constructor throws.
Error pages¶
router.showErrorPage() renders any component as an error page without changing the URL or the
history:
@handles(RouteNotFoundError)
async notFound(error: RouteNotFoundError): Promise<void> {
await this.app.router.showErrorPage(NotFoundPage, {
error,
title: "Page not found",
});
}
The error page is an ordinary @component. It does not need @page and is not registered with
the router:
@component({ tagName: "not-found-page" })
export default class NotFoundPage extends CustomElement<MyApp> {
override markup = () => html`
<section class="not-found">
<h1>404</h1>
<p>Nothing was found at <code>{{! this.app.pathname }}</code>.</p>
<a href="/">Back to the start</a>
</section>
`;
}
| Option | Default | Meaning |
|---|---|---|
error |
— | The error being handled; see below. |
title |
— | Document title (passed through titleGenerator). |
layout |
base layout | Layout to render the page in. |
target |
default target | Target selector inside the layout. |
args |
— | Arguments passed to the error page component. |
While an error page is shown, router.currentRoute is null, route parameters are empty and the
sublayout chain is removed. The normal route start, layout-change and finish events still apply.
Error pages do not run authorization, so choose a layout that unauthenticated visitors may
see.
Always pass the error
The router remembers which navigation produced the error. If the user has started a newer
navigation by the time a slow handler calls showErrorPage, the call is ignored instead of
replacing the newer page. A new navigation also cancels an error page that is still
initializing, and showing a second error page while one is initializing is rejected, which
prevents endless loops when the error page itself fails.
Initialization errors¶
When a component fails to initialize, its initComplete promise rejects with an
ElementInitializationError:
| Property | Description |
|---|---|
element |
The component instance that failed. |
elementName |
Its tag name. |
cause |
The underlying error (dependency, hook, render or child failure). |
The same error object propagates unchanged to parent components waiting for the child, and the error handlers see it once.
The complete list of error classes is in the error reference.