Skip to content

@try/@catch Control Flow  #51941

@crutchcorn

Description

@crutchcorn

Which @angular/* package(s) are relevant/related to the feature request?

common, compiler-cli, compiler, core

Description

tl;dr: I am proposing a new @try/@catch control flow to catch errors that occur in Angular component's constructor method.

@try {
	<error-throwing-component/>
} @catch (e: any) {
	<handle-error [error]="e"/>
}

A longer-form blog post explaining this problem and the solution can be found here: https://unicorn-utterances.com/posts/angular-constructor-error-behavior

The problem

When rendering the following:

import 'zone.js/dist/zone';
import { bootstrapApplication } from '@angular/platform-browser';
import { Component } from '@angular/core';

@Component({
  selector: 'throw-an-error',
  standalone: true,
  template: `<p>🙈</p>`,
})
class ErrorComponent {
  constructor() {
    throw 'This is an error';
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ErrorComponent],
  template: `
    <p>Before</p>
    <!-- Try hiding and showing this line -->
    <throw-an-error/>
    <!-- This never shows up -->
    <p>After</p>
  `,
})
class AppComponent {}

bootstrapApplication(AppComponent);

The <p>Before</p> renders, but the <throw-an-error/> does not. Confusingly (to many), the <p>After</p> does not render either. This is because Angular's template compiler doesn't wrap component instances in a try/catch, which leads to the error propagating upward and breaking any UI rendering past the thrown constructor error.

Proposed solution

The proposed solution

To solve for this issue, I'd like to propose (and offer to implement) the following syntax:

@try {
	<error-throwing-component/>
} @catch (e: any) {
	<handle-error [error]="e"/>
}

Which would allow us to catch any constructor errors while allowing the rest of the components to be rendered as-expected. I would reasonably expect this to be compiled to some pseudo-code akin to:

let attemptedRender = null;
try {
	attemtedRender = renderComponent(ErrorThrowingComponent)
} catch (e) {
	cleanup(attemptedRender)
	render(HandleErrorComponent, {inputs: {error: e}})
}

Alternatives considered

Userland solutions

While <component/> wouldn't be able to recover from the error in runtime, it's possible to wrap this component in a try/catch block by moving it to a ViewContainerRef createComponent call:

@Component({
  selector: 'error-catcher',
  standalone: true,
  imports: [NgIf],
  template: `
  <div *ngIf="error">
    <h1>There was an error</h1>
  </div>
  <ng-template #compTemp></ng-template>
  `,
})
class ErrorCatcher implements OnInit {
  @ViewChild('compTemp') compTemp!: TemplateRef<any>;
  @Input({ required: true }) comp!: any;

  containerRef = inject(ViewContainerRef);

  error: any = null;

  ngOnInit() {
    try {
      this.containerRef.createComponent(this.comp);
    } catch (e) {
      this.error = e;
    }
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [ErrorCatcher],
  template: `
    <p>Before</p>
    <error-catcher [comp]="comp"/>
    <p>After</p>
  `,
})
class AppComponent {
  comp = ErrorComponent;
}

We can even expand this functionality to accept inputs and handle outputs mostly as expected:

Demo StackBlitz showing a user-land fix to the error boundary problem

Alternative demo using a structural directive instead

However, this comes with substancial issues:

  • The syntax and tooling are not consistent with other Angular components
  • Outputs and inputs are very loose typings now, which may lead to more bugs, not less
  • Outputs are not bound in the same way, requiring weird switch/case style coding and manually typing

Prior art

This idea was directly inspired by React's ErrorBoundary system, which similarly handles errors thrown during the render function.

Note that neither this proposal, nor React's ErrorBoundary handle errors that occur in event handlers, only the runtime.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // Example "componentStack":
    //   in ComponentThatThrows (created by App)
    //   in ErrorBoundary (created by App)
    //   in div (created by App)
    //   in App
    logErrorToMyService(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return this.props.fallback;
    }

    return this.props.children;
  }
}

// ...

<ErrorBoundary fallback={<p>Something went wrong</p>}>
  <Profile />
</ErrorBoundary>

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions