-
Notifications
You must be signed in to change notification settings - Fork 27.1k
Description
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/casestyle 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
ErrorBoundaryhandle 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>