Skip to main content

Error Boundaries

In SSR applications errors can occur in different stages:

  • On server initialization
  • At runtime, when server handle user request
  • On browser page loading
  • At runtime, during hydration, or when user interacts with page and make SPA-navigations

Server initialization errors block application deployment, easy to find and almost never reach the user. Moreover, tramvai provides a module @tramvai/module-error-interceptor, that adds global error handlers to the application on the server-side.

Errors during page loading are often caused by network problems. Client application may be more resistant to bad connections with different techniques - e.g. Service Worker, retry resources requests - but such techniques will be specific to each application.

Runtime errors, both on server in browser, can be critical and require send error page in reply to the user with some 5xx status.

This guide will be focused how to customize and show error pages for the users in different scenarios.

Page Error Boundary

If the first rendering of the page on the server fails, tramvai will try to render the page a second time, but already using the Error Boundary with fallback component. Also, we use React Error Boundaries under the hood, so the error fallback will render in case of any rendering errors in the browser.

Page Error Boundary only wrap Page Component, and Nested Layout with Root Layout will be rendered as usual.

Here is a list of cases when Page Error Boundary will be rendered with priority over Root Error Boundary:

You can provide default fallback for all pages, and specific fallback to concrete page. In this fallback components tramvai will pass url and error properties:

DefaultErrorBoundary.tsx
export const DefaultErrorBoundary = ({ url, error }) => {
return (
<div>
<h1>Something wrong!</h1>
<p>Location: {url.path}</p>
<p>Error: {error.message}</p>
</div>
);
};
info

Default response status for server-side Error Boundary - 500. This status can be changed by adding httpStatus property to Error object.

Default fallback

You can provide default error fallback component for all pages by using token DEFAULT_ERROR_BOUNDARY_COMPONENT:

import { DEFAULT_ERROR_BOUNDARY_COMPONENT } from '@tramvai/tokens-render';

const provider = {
provide: DEFAULT_ERROR_BOUNDARY_COMPONENT,
useValue: DefaultErrorBoundary,
};

This error boundary is also used by client render/hydration in cases where an error happens before the page is being rehydrated or rendered, or when Nested Layout or Root Layout fails. The boundary wouldn't work if you're providing CUSTOM_RENDER token for your client rendering process; you should handle it yourself.

Specific fallback

There are two ways to add a specific error boundary to the page.

_error.tsx

You can declare an error boundary to the page by adding a file called _error.tsx near the page component:

src
└── pages
└── login
└── index.tsx
└── _error.tsx

The component signature still be the same as the DefaultErrorBoundary, so properties error and url will be available here.

For manually created route

Concrete fallback for any of application pages can be registered in route configuration:

⌛ Create fallback component in pages directory:

pages/comments-fallback.tsx
export const CommentsFallback = ({ error }) => {
return (
<div>
<h1>Unexpected Error</h1>
<p>Can't show comments, reason: {error.message}</p>
</div>
);
};

And we will get this file structure:

src
└── pages
├── comments.tsx
└── comments-fallback.tsx

⌛ Add errorBoundaryComponent to route configuration:

import { SpaRouterModule } from '@tramvai/modules-router';

createApp({
modules: [
SpaRouterModule.forRoot([
{
name: 'comments',
path: '/comments/',
config: {
pageComponent: '@/pages/comments',
errorBoundaryComponent: '@/pages/comments-fallback',
},
},
]),
],
});

Root Error Boundary

If a critical error occurred during the request handling, e.g. Page Error Boundary rendering was unsuccessful, or an exception has been thrown out in any CommandLineRunner stages before rendering, tramvai provides an opportunity to render both custom 5xx and 4xx page. Root Boundary works only on server side.

Here is a list of cases when Root Error Boundary will be rendered:

  • when NotFoundError thrown out in Actions (4xx)
  • when HttpError thrown out in Actions (4xx or 5xx)
  • Router Guard block navigation (500)
  • Route is not found (404)
  • Error Boundary forced render in Guard or Action (4xx or 5xx)
  • Request Limiter blocked request (429)
  • React page component rendering failed (500)
  • Other unexpected server-side errors, for example Fastify Errors (4xx or 5xx)
info

Root Error Boundary works only server-side

You can provide this boundary by creating a file error.tsx in a project source directory with default export of the component. You also can override path for error.tsx with option rootErrorBoundaryPath in tramvai configuration for application; path relative to project root.

This components will have access to error and url props, and need to render complete HTML page, e.g.:

src/error.tsx
import React, { useEffect, useState } from 'react';
import type { ErrorBoundaryComponent } from '@tramvai/react';

import styles from './styles.module.css';

const RootErrorBoundary: ErrorBoundaryComponent = ({ error, url }) => {
const message = `Error ${error.message} at ${url.path}`;

const handleClick = async () => {
await fetch('feedback', {
method: 'post',
body: JSON.stringify(error),
});
};

return (
<html lang="ru">
<head>
<title>{message}</title>
</head>
<body>
<h1 className={styles.title}>Root Error Boundary</h1>

<button onClick={handleClick}>Send feedback</button>
</body>
</html>
);
};

export default RootErrorBoundary;

Tramvai will add necessary styles and scripts to the server response if you are using them.

Also, this component will be hydrated, so, you can use any React features here.

caution

If this component also crashes at the rendering stage, in case of HttpError user will get an empty response.body, otherwise finalhandler library will send default HTML page with some information about error.

RootErrorBoundary also generates the static page on the tramvai static command by path _errors/5xx/index.html in your output for static. In this scenario of rendering, the error object in props would be equal to this json:

{
"name": "STATIC_ROOT_ERROR_BOUNDARY_ERROR",
"message": "Default error for root error boundary"
}

You can rely on these constant values of errors to provide different renders for RootErrorBoundary. It's possible to override this error for the static page by providing a value for token STATIC_ROOT_ERROR_BOUNDARY_ERROR_TOKEN from @tramvai/tokens-server.

How to

Force render Page Error Boundary in Action

caution

setPageErrorEvent - experimental API, and can be changed in future releases.

By default, errors in actions are skipped on server-side, and tramvai try to execute failed actions again in browser. If the action failed to fetch critical data for page rendering, and you want to change response status code, and show error page for user, you need to dispath setPageErrorEvent action:

import { declareAction } from '@tramvai/core';
import { HttpError } from '@tinkoff/errors';
import { setPageErrorEvent } from '@tramvai/module-router';

const action = declareAction({
name: 'action',
fn() {
// set custom response status, `500` by default
const error = new HttpError({ httpStatus: 410 });
this.dispatch(setPageErrorEvent(error));
},
});

Force render Page Error Boundary in Router Guard

caution

setPageErrorEvent - experimental API, and can be changed in future releases.

Errors in router guards will be ignored by default. Like the previous reciepe, if you need to render page fallback from guard, you can dispatch setPageErrorEvent inside:

import { STORE_TOKEN } from '@tramvai/module-common';
import { ROUTER_GUARD_TOKEN, setPageErrorEvent } from '@tramvai/module-router';
import { HttpError } from '@tinkoff/errors';

const provider = {
provide: ROUTER_GUARD_TOKEN,
multi: true,
useFactory: ({ store }) => {
return async ({ to }) => {
// guards runs for every pages, and we need to check next path if want to show error only on specific routes
if (to?.path === '/some-restricted-page/') {
const error = new HttpError({ httpStatus: 503 });
store.dispatch(setPageErrorEvent(error));
}
};
},
deps: {
store: STORE_TOKEN,
},
};

Page Error Boundary errors monitoring

Server-side errors can be found in application logs by event send-server-error.

Client-side error can be found in application logs by event component-did-catch.

If you need custom monitoring of client-side errors, you can provide ERROR_BOUNDARY_TOKEN handler, e.g.:

import { ERROR_BOUNDARY_TOKEN } from '@tramvai/react';

const provider = provide({
provide: ERROR_BOUNDARY_TOKEN,
useFactory: () => {
const log = logger('error-boundary');

return function logErrorBoundary(error, info) {
log.error({
event: 'component-did-catch',
error,
info,
});
};
},
deps: {
logger: LOGGER_TOKEN,
},
});

Known Issues

React Hydration Mismatch Due to User Browser Extensions

When rendering a Root Error Boundary:

  • The server renders the boundary, which includes the HTML, head, and body.
  • The server injects the necessary assets for the page to function into the head.
  • On the client-side, the page gets hydrated.

At first glance, everything seems to be functioning as expected. However, if a user has a browser extension that also writes something to the head, React sees this as a mismatch during hydration. React then re-renders the boundary without the assets, which were only inserted during server-side rendering. This leads to the page breaking since the links to the assets are removed, causing the CSS (also maybe JS) to stop working.

This GitHub Issue has a list of workarounds.

Workarounds

  1. Implement a HEAD Wrapper which renders your head content during server-side rendering, but during browser-side rendering it uses the current head content, ensuring that any modifications made by extensions are preserved during React hydration.
if (process.env.BROWSER) {
return <head dangerouslySetInnerHTML={{ __html: document.head.innerHTML }} />;
}

return <head>{/* YOUR CODE HERE */}</head>;
  1. Remove Scripts and Links Outside of HEAD and Extension Scripts. This solution targets and removes certain scripts and links, including those added by extensions, to mitigate the issue:
if (process.env.BROWSER) {
document
.querySelectorAll(
'html > _:not(body, head), script[src_="extension://"], link[href*="extension://"], script[src*="scr.kaspersky-labs.com"], link[href*="scr.kaspersky-labs.com"]'
)
.forEach((s) => {
s?.parentNode?.removeChild(s);
});
}