Handling errors

When your code throws, AngularLynx reports the error to Lynx and keeps it as text you can show. To keep one broken part from taking down a screen, wrap it in @boundary and show a fallback instead.

What happens when an error isn't caught

An error is uncaught when nothing handles it, for example a throw in a template binding, a lifecycle hook or an event handler. What you see depends on where the app runs:

Your app runs inWhat you see
Lynx ExplorerLynx's red error screen, LogBox, with the message and stack trace
The web previewThe error in your browser's DevTools console
The apps ng build --platform makesNo error screen. These apps don't include LogBox, so show errors on screen while you test them

What provideRenderer() sets up

Every app's config calls provideRenderer(). Among other things, it registers LynxErrorHandler as Angular's ErrorHandler. For each uncaught error, LynxErrorHandler:

  1. saves the error's name, message and stack as text in globalThis.__lynxLastError, so you can show it on screen;
  2. reports the error to Lynx, which opens the error screen in Lynx Explorer.

AngularLynx also installs global onerror and onunhandledrejection handlers. They save and report errors that happen outside Angular, such as a promise rejection that nothing handles.

Add your own handling

Extend LynxErrorHandler and call super to keep the default behavior:

src/app/app-error-handler.ts
import { inject, Injectable } from '@angular/core';
import { LynxErrorHandler, LynxLogger } from '@blotch/angular-lynx';

@Injectable()
export class AppErrorHandler extends LynxErrorHandler {
  readonly #logger = inject(LynxLogger);

  override handleError(error: unknown): void {
    // Keep the default: save the error and report it to Lynx.
    super.handleError(error);
    // Also write it to .dev-logs while `ng serve` runs.
    this.#logger.error(String(error));
  }
}

Provide it after provideRenderer(), so it replaces the default:

src/app/app.config.ts
import {
  type ApplicationConfig,
  ErrorHandler,
  provideZonelessChangeDetection,
} from '@angular/core';
import { provideRenderer } from '@blotch/angular-lynx';
import { AppErrorHandler } from './app-error-handler';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRenderer(),
    { provide: ErrorHandler, useClass: AppErrorHandler },
  ],
};
  • Don't write signals directly in the handler. Angular can call it in the middle of rendering, where a signal write throws NG0600. Defer the write with queueMicrotask(), as Show errors on screen does.
  • Uploading reports to a server isn't supported yet. Most errors happen on the main thread, which has no network access. See Network requests.

Contain failures with @boundary

@boundary catches errors while Angular creates or updates the views inside it. When one happens, Angular removes that part of the screen and renders the @error block in its place. The rest of the screen keeps working.

Developer preview

@boundary needs Angular 22.2 or newer, and it's a developer preview API. Its syntax and behavior may change in later Angular releases.

<view>
  <text>Portfolio</text>

  @boundary {
    <app-price-tile symbol="LYNX" [price]="price()" />
  } @error (let err, retry = $reset) {
    <text>{{ err.message }}</text>
    <view (bindtap)="restorePrice(); retry()">
      <text>Retry</text>
    </view>
  }

  <text>Everything outside the boundary keeps rendering.</text>
</view>
  • It works on Lynx with no setup. AngularLynx swaps the native elements for the fallback in place.
  • err is the caught error, and retry() ($reset) renders the primary block again. Fix the cause first, as restorePrice() does here, or the block throws again.
  • Use (bindtap) for the retry button. Angular's own examples use (click), but on Lynx a tap arrives as bindtap. See Handling taps and events.
  • Everything else, such as when conditions, nested boundaries and resource() errors, works as Angular documents it in Error boundaries with @boundary.

What a boundary catches

CaughtNot caught
Component and directive constructorsEvent handlers such as (bindtap). These go to the app's ErrorHandler
Template bindings and interpolationsErrors thrown inside the @error block itself. These go to the next boundary up
Lifecycle hooksAsync work that never feeds back into rendering
@for and other control-flow expressionsProjected (<ng-content>) content, which belongs to the parent's view
Effects declared inside the boundary
Lazily and dynamically created components

Report errors a boundary caught

Angular passes caught errors to the app's ErrorHandler too, through its onViewError() method. LynxErrorHandler implements it:

  • It saves the error in globalThis.__lynxLastError, like any other error.
  • It reports the error to Lynx as a warning, not an error. The user is already looking at a working fallback, so Lynx Explorer doesn't open its error screen, and monitoring doesn't count a crash.

To act on caught errors yourself, override onViewError():

src/app/app-error-handler.ts
import { type ErrorDetails, inject, Injectable, signal } from '@angular/core';
import { LynxErrorHandler } from '@blotch/angular-lynx';

/** Messages of errors that boundaries caught, newest first. */
@Injectable({ providedIn: 'root' })
export class CaughtErrors {
  readonly messages = signal<readonly string[]>([]);
}

@Injectable()
export class AppErrorHandler extends LynxErrorHandler {
  readonly #caught = inject(CaughtErrors);

  override onViewError(error: Error, details: ErrorDetails): void {
    super.onViewError(error, details);
    // details.declarationType is the component that threw.
    queueMicrotask(() =>
      this.#caught.messages.update((list) => [error.message, ...list]),
    ); 
  }
}
Don't write signals in

onViewError() Angular calls onViewError() synchronously, in the middle of rendering. Writing a signal there throws NG0600 and can break the boundary. Defer the write with queueMicrotask(), as above.