Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: Kibana React Error Boundary #169010

Closed
wants to merge 13 commits into from
Closed
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ packages/shared-ux/card/no_data/impl @elastic/appex-sharedux
packages/shared-ux/card/no_data/mocks @elastic/appex-sharedux
packages/shared-ux/card/no_data/types @elastic/appex-sharedux
packages/shared-ux/chrome/navigation @elastic/appex-sharedux
packages/shared-ux/error_boundary @elastic/appex-sharedux
packages/shared-ux/file/context @elastic/appex-sharedux
packages/shared-ux/file/image/impl @elastic/appex-sharedux
packages/shared-ux/file/image/mocks @elastic/appex-sharedux
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@
"@kbn/shared-ux-card-no-data-mocks": "link:packages/shared-ux/card/no_data/mocks",
"@kbn/shared-ux-card-no-data-types": "link:packages/shared-ux/card/no_data/types",
"@kbn/shared-ux-chrome-navigation": "link:packages/shared-ux/chrome/navigation",
"@kbn/shared-ux-error-boundary": "link:packages/shared-ux/error_boundary",
"@kbn/shared-ux-file-context": "link:packages/shared-ux/file/context",
"@kbn/shared-ux-file-image": "link:packages/shared-ux/file/image/impl",
"@kbn/shared-ux-file-image-mocks": "link:packages/shared-ux/file/image/mocks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
type AppUnmount,
type ScopedHistory,
} from '@kbn/core-application-browser';
import { ErrorBoundary, ErrorBoundaryKibanaProvider } from '@kbn/shared-ux-error-boundary';

import type { Mounter } from '../types';
import { AppNotFound } from './app_not_found_screen';

Expand Down Expand Up @@ -53,6 +55,7 @@ export const AppContainer: FC<Props> = ({
}: Props) => {
const [showSpinner, setShowSpinner] = useState(true);
const [appNotFound, setAppNotFound] = useState(false);
const [appError, setAppError] = useState<Error | null>(null);
const elementRef = useRef<HTMLDivElement>(null);
const unmountRef: MutableRefObject<AppUnmount | null> = useRef<AppUnmount>(null);

Expand Down Expand Up @@ -87,9 +90,9 @@ export const AppContainer: FC<Props> = ({
setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount),
})) || null;
} catch (e) {
// TODO: add error UI
// eslint-disable-next-line no-console
console.error(e);
setAppError(e);
} finally {
if (elementRef.current) {
setShowSpinner(false);
Expand All @@ -113,7 +116,11 @@ export const AppContainer: FC<Props> = ({
theme$,
]);

return (
return appError ? (
<ErrorBoundaryKibanaProvider>
<ErrorBoundary as="callout" error={appError} />
</ErrorBoundaryKibanaProvider>
) : (
<Fragment>
{appNotFound && <AppNotFound />}
{showSpinner && !appNotFound && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@kbn/core-analytics-browser-mocks",
"@kbn/core-analytics-browser",
"@kbn/shared-ux-router",
"@kbn/shared-ux-error-boundary",
],
"exclude": [
"target/**/*",
Expand Down
16 changes: 16 additions & 0 deletions packages/shared-ux/error_boundary/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
id: sharedUX/KibanaErrorBoundary
slug: /shared-ux/error_boundary/kibana_error_boundary
title: Kibana Error Boundary
description: Container to catch errors thrown by child component
tags: ['shared-ux', 'component', 'error', 'error_boundary']
date: 2023-10-03
---

## Description

## API

## EUI Promotion Status

This component is specialized for error messages internal to Kibana and is not intended for promotion to EUI.
13 changes: 13 additions & 0 deletions packages/shared-ux/error_boundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

export { ErrorBoundary } from './src/ui/error_boundary';
export {
ErrorBoundaryKibanaProvider,
ErrorBoundaryProvider,
} from './src/services/error_boundary_services';
13 changes: 13 additions & 0 deletions packages/shared-ux/error_boundary/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/shared-ux/error_boundary'],
};
5 changes: 5 additions & 0 deletions packages/shared-ux/error_boundary/kibana.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/shared-ux-error-boundary",
"owner": "@elastic/appex-sharedux"
}
20 changes: 20 additions & 0 deletions packages/shared-ux/error_boundary/mocks/src/jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { ErrorService } from '../../src/services/error_service';
import { ToastsService } from '../../src/services/toasts_service';
import { ErrorBoundaryServices } from '../../types';

export const getServicesMock = (): ErrorBoundaryServices => {
const reloadWindow = jest.fn().mockResolvedValue(undefined);
return {
reloadWindow,
errorService: new ErrorService(),
toastsService: new ToastsService({ reloadWindow: jest.fn() }),
};
};
74 changes: 74 additions & 0 deletions packages/shared-ux/error_boundary/mocks/src/storybook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import { action } from '@storybook/addon-actions';
import { ErrorService } from '../../src/services/error_service';
import { ToastsService } from '../../src/services/toasts_service';
import { ErrorBoundaryServices } from '../../types';

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Params {}

export class ErrorBoundaryStorybookMock extends AbstractStorybookMock<{}, ErrorBoundaryServices> {
propArguments = {};

serviceArguments = {};

dependencies = [];

getServices(params: Params = {}): ErrorBoundaryServices {
const reloadWindowAction = action('Reload window');
const reloadWindow = () => {
reloadWindowAction();
};

return {
...params,
reloadWindow,
errorService: new ErrorService(),
toastsService: new ToastsService({ reloadWindow }),
};
}

getProps(params: Params) {
return params;
}
}

interface UserTableEntry {
id: string;
firstName: string | null | undefined;
lastName: string;
action: string;
}

export const getMockUserTable = (): UserTableEntry[] => {
const users: UserTableEntry[] = [];

users.push({
id: 'user-123',
firstName: 'Rodger',
lastName: 'Turcotte',
action: 'Rodger.Turcotte',
});
users.push({
id: 'user-345',
firstName: 'Bella',
lastName: 'Cremin',
action: 'Bella23',
});
users.push({
id: 'user-678',
firstName: 'Layne',
lastName: 'Franecki',
action: 'The_Real_Layne_2',
});

return users;
};
6 changes: 6 additions & 0 deletions packages/shared-ux/error_boundary/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "@kbn/shared-ux-error-boundary",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { FC, useContext } from 'react';

import { ErrorBoundaryServices } from '../../types';
import { ErrorService } from './error_service';
import { StatefulToastList, ToastsService } from './toasts_service';

const Context = React.createContext<ErrorBoundaryServices | null>(null);

/**
* A Context Provider for Jest and Storybooks
*/
export const ErrorBoundaryProvider: FC<ErrorBoundaryServices> = ({
children,
reloadWindow,
errorService,
toastsService,
}) => {
return (
<Context.Provider value={{ reloadWindow, errorService, toastsService }}>
{children}
<StatefulToastList toasts$={toastsService.toasts$} />
</Context.Provider>
);
};

/**
* Kibana-specific Provider that maps dependencies to services.
*/
export const ErrorBoundaryKibanaProvider: FC = ({ children }) => {
const reloadWindow = () => window.location.reload();
const toastsService = new ToastsService({ reloadWindow });

const value: ErrorBoundaryServices = {
reloadWindow,
errorService: new ErrorService(),
toastsService,
};

return (
<Context.Provider value={value}>
{children}
<StatefulToastList toasts$={toastsService.toasts$} />
</Context.Provider>
);
};

/**
* React hook for accessing pre-wired services.
*/
export function useErrorBoundary(): ErrorBoundaryServices {
const context = useContext(Context);
if (!context) {
throw new Error(
'Kibana Error Boundary Context is missing. Ensure your component or React root is wrapped with Kibana Error Boundary Context.'
);
}

return context;
}
75 changes: 75 additions & 0 deletions packages/shared-ux/error_boundary/src/services/error_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';

const MATCH_CHUNK_LOADERROR = /ChunkLoadError/;

/**
* Kibana Error Boundary Services: Error Service
* Each Error Boundary tracks an instance of this class
* @internal
*/
export class ErrorService {
/**
* Determines if the error fallback UI should appear as an apologetic but promising "Refresh" button,
* or treated with "danger" coloring and include a detailed error message.
*/
private getIsFatal(error: Error) {
const isChunkLoadError = MATCH_CHUNK_LOADERROR.test(error.name);
return !isChunkLoadError; // "ChunkLoadError" is recoverable by refreshing the page
}

/**
* Derive the name of the component that threw the error
*/
private getErrorComponentName(errorInfo: Partial<React.ErrorInfo> | null) {
let errorComponentName: string | null = null;
const stackLines = errorInfo?.componentStack?.split('\n');
const errorIndicator = /^ at (\S+).*/;

if (stackLines) {
let i = 0;
while (i < stackLines.length - 1) {
// scan the stack trace text
if (stackLines[i].match(errorIndicator)) {
// extract the name of the bad component
errorComponentName = stackLines[i].replace(errorIndicator, '$1');
if (errorComponentName) {
break;
}
}
i++;
}
}

return errorComponentName;
}

public registerError(
error: Error,
errorInfo: Partial<React.ErrorInfo> | null
): ErrorServiceError {
const isFatal = this.getIsFatal(error);
const name = this.getErrorComponentName(errorInfo);

return {
error,
errorInfo,
isFatal,
name,
};
}
}

interface ErrorServiceError {
error: Error;
errorInfo?: Partial<React.ErrorInfo> | null;
name: string | null;
isFatal: boolean;
}
Loading