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

KibanaErrorBoundary initial implementation #168754

Merged
merged 59 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d79d5c5
KibanaErrorBoundary with Fatal error implementation
tsullivan Oct 12, 2023
3f6d7bd
Reporting management app error boundary
tsullivan Oct 13, 2023
8ae90f5
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 13, 2023
83bb621
implement "soft" error message
tsullivan Oct 13, 2023
5d407bd
side effects should not appear directly inside the body of a component
tsullivan Oct 13, 2023
4700a45
reset reporting
tsullivan Oct 15, 2023
bda5264
Reference implementation for SharedUX management apps
tsullivan Oct 15, 2023
957ee1b
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 15, 2023
45d8560
sync with code changes from poc branch
tsullivan Oct 16, 2023
d4a4bda
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 16, 2023
1f8e327
pr cleanup
tsullivan Oct 16, 2023
e8ebb2d
re-fix useMemo
tsullivan Oct 16, 2023
ba420c5
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 16, 2023
dc15a23
polish
tsullivan Oct 16, 2023
cf81e93
change user-facing text per feedback.
tsullivan Oct 16, 2023
8784b20
Add jest unit testing
tsullivan Oct 16, 2023
c2559e8
Ensure the ErrorBoundary package is part of the shared-deps bundle
tsullivan Oct 17, 2023
e9cf725
Rename to KibanaErrorBoundary
tsullivan Oct 17, 2023
047b271
fix package build reference
tsullivan Oct 17, 2023
d0757f3
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 17, 2023
4bc61e9
--wip-- [skip ci]
tsullivan Oct 17, 2023
3e37880
Revert "fix package build reference"
tsullivan Oct 17, 2023
a907a02
Revert "Ensure the ErrorBoundary package is part of the shared-deps b…
tsullivan Oct 17, 2023
68cf0bf
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 17, 2023
539415f
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 17, 2023
58eb8a9
Revert "--wip-- [skip ci]"
tsullivan Oct 17, 2023
21573a1
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 17, 2023
e281111
Replace EuiErrorBoundary in KibanaRenderContextProvider
tsullivan Oct 17, 2023
0e48b4a
Merge branch 'sharedux/error-boundary-ii' of github.com:tsullivan/kib…
tsullivan Oct 17, 2023
a25f52d
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 17, 2023
015769d
Revert changes to individual management pages
tsullivan Oct 17, 2023
0f306f7
update snapshot core overlays browser-internal services
tsullivan Oct 18, 2023
f0a58ce
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 18, 2023
bb49260
Update design per feedback
tsullivan Oct 18, 2023
f76ff90
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 18, 2023
5925e34
Ensure the ErrorBoundary package is part of the shared-deps bundle [II]
tsullivan Oct 18, 2023
d2aaad4
chore(NA): include @kbn/shared-ux-error-boundary in ui shared deps src
mistic Oct 18, 2023
e0e3099
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Oct 18, 2023
ed73007
implement copy error to clipboard
tsullivan Oct 18, 2023
b864106
Merge branch 'sharedux/error-boundary-ii' of github.com:tsullivan/kib…
tsullivan Oct 18, 2023
c34a14a
increase dataViewManagement bundle limit
tsullivan Oct 18, 2023
7309f83
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 18, 2023
5a17940
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 18, 2023
2ab706a
Improve storybooks
tsullivan Oct 18, 2023
57c8506
Update content per review
tsullivan Oct 18, 2023
1c420da
add an api doc
tsullivan Oct 18, 2023
f6f6c66
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 18, 2023
3f88017
fix jest test
tsullivan Oct 19, 2023
251a1ce
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 19, 2023
a5ce5ff
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 19, 2023
0287190
Context value is a single object that never changes
tsullivan Oct 19, 2023
cb43013
Services as a distinct prop to the internal component
tsullivan Oct 20, 2023
438742b
use explicit type for React component
tsullivan Oct 20, 2023
94fc6ee
use React event naming
tsullivan Oct 20, 2023
248576e
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 20, 2023
238a361
fix ts error
tsullivan Oct 20, 2023
70895c5
unit test for KibanaErrorService
tsullivan Oct 20, 2023
23c77fa
Merge branch 'main' into sharedux/error-boundary-ii
tsullivan Oct 20, 2023
fbdadcd
Merge branch 'main' into sharedux/error-boundary-ii
kibanamachine Oct 21, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,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
1 change: 1 addition & 0 deletions packages/kbn-ui-shared-deps-src/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ webpack_cli(
"//packages/kbn-peggy",
"//packages/kbn-peggy-loader",
"//packages/kbn-rison",
"//packages/shared-ux/error-boundary",
],
output_dir = True,
args = [
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-ui-shared-deps-src/src/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const externals = {
'@kbn/std': '__kbnSharedDeps__.KbnStd',
'@kbn/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet',
'@kbn/rison': '__kbnSharedDeps__.KbnRison',
'@kbn/shared-ux-error-boundary': '__kbnSharedDeps__.KibanaErrorBoundary',
history: '__kbnSharedDeps__.History',
classnames: '__kbnSharedDeps__.Classnames',
'@tanstack/react-query': '__kbnSharedDeps__.ReactQuery',
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-ui-shared-deps-src/src/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const KbnEsQuery = require('@kbn/es-query');
export const KbnStd = require('@kbn/std');
export const SaferLodashSet = require('@kbn/safer-lodash-set');
export const KbnRison = require('@kbn/rison');
export const KibanaErrorBoundary = require('@kbn/shared-ux-error-boundary');
export const History = require('history');
export const Classnames = require('classnames');
export const ReactQuery = require('@tanstack/react-query');
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.
10 changes: 10 additions & 0 deletions packages/shared-ux/error_boundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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 { KibanaErrorBoundary } from './src/ui/error_boundary';
export { KibanaErrorBoundaryProvider } 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"
}
12 changes: 12 additions & 0 deletions packages/shared-ux/error_boundary/mocks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* 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 { BadComponent } from './src/bad_component';
export { ChunkLoadErrorComponent } from './src/chunk_load_error_component';
export { getServicesMock } from './src/jest';
export { KibanaErrorBoundaryStorybookMock } from './src/storybook';
31 changes: 31 additions & 0 deletions packages/shared-ux/error_boundary/mocks/src/bad_component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { action } from '@storybook/addon-actions';
import React, { useState } from 'react';

export const BadComponent = () => {
const [hasError, setHasError] = useState(false);

if (hasError) {
throw new Error('This is an error to show the test user!'); // custom error
}

const clickedForError = action('clicked for error');
const handleClick = () => {
clickedForError();
setHasError(true);
};

return (
<EuiButton onClick={handleClick} data-test-subj="clickForErrorBtn">
Click for error
</EuiButton>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { action } from '@storybook/addon-actions';
import React, { useState } from 'react';

export const ChunkLoadErrorComponent = () => {
const [hasError, setHasError] = useState(false);

if (hasError) {
const chunkError = new Error('Could not load chunk');
chunkError.name = 'ChunkLoadError'; // specific error known to be recoverable with a click of a refresh button
throw chunkError;
}

const clickedForError = action('clicked for error');
const handleClick = () => {
clickedForError();
setHasError(true);
};

return (
<EuiButton onClick={handleClick} fill={true} data-test-subj="clickForErrorBtn">
Click for error
</EuiButton>
);
};
18 changes: 18 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,18 @@
/*
* 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 { KibanaErrorService } from '../../src/services/error_service';
import { KibanaErrorBoundaryServices } from '../../types';

export const getServicesMock = (): KibanaErrorBoundaryServices => {
const reloadWindow = jest.fn().mockResolvedValue(undefined);
return {
reloadWindow,
errorService: new KibanaErrorService(),
};
};
43 changes: 43 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,43 @@
/*
* 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 { KibanaErrorService } from '../../src/services/error_service';
import { KibanaErrorBoundaryServices } from '../../types';

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

export class KibanaErrorBoundaryStorybookMock extends AbstractStorybookMock<
{},
KibanaErrorBoundaryServices
> {
propArguments = {};

serviceArguments = {};

dependencies = [];

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

return {
...params,
reloadWindow,
errorService: new KibanaErrorService(),
};
}

getProps(params: Params) {
return params;
}
}
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,55 @@
/*
* 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, useMemo } from 'react';

import { KibanaErrorBoundaryServices } from '../../types';
import { KibanaErrorService } from './error_service';

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

/**
* A Context Provider for Jest and Storybooks
*/
export const KibanaErrorBoundaryDepsProvider: FC<KibanaErrorBoundaryServices> = ({
children,
reloadWindow,
errorService,
}) => {
return <Context.Provider value={{ reloadWindow, errorService }}>{children}</Context.Provider>;
};

/**
* Kibana-specific Provider that maps dependencies to services.
*/
export const KibanaErrorBoundaryProvider: FC = ({ children }) => {
// control side-effects of rendering with useMemo
tsullivan marked this conversation as resolved.
Show resolved Hide resolved
const reloadWindow = useMemo(() => () => window.location.reload(), []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably should be

Suggested change
const reloadWindow = useMemo(() => () => window.location.reload(), []);
const reloadWindow = useCallback(() => window.location.reload(), []);

But I am not sure that there is any benefit to it, since value below is always a new object and children will have to re-render. see https://legacy.reactjs.org/docs/context.html#caveats

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

realoadWindow is so tiny, it is probably more efficient to create a new function every re-render than to memoize it:

Suggested change
const reloadWindow = useMemo(() => () => window.location.reload(), []);
const reloadWindow = () => window.location.reload();

For the context provider value object to always have the same identity, so that other components don't re-render if this component re-renders, the context value could be a single object:

const value = useMemo(() => new KibanaErrorBoundaryServices(), []);

return <Context.Provider value={value}>{children}</Context.Provider>;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for these suggestions!

Since KibanaErrorBoundaryServices is just an interface, I'll go with:

  const value: KibanaErrorBoundaryServices = useMemo(
    () => ({
      reloadWindow: () => window.location.reload(),
      errorService: new KibanaErrorService(),
    }),
    []
  );

const errorService = useMemo(() => new KibanaErrorService(), []);

const value: KibanaErrorBoundaryServices = {
reloadWindow,
errorService,
};

return <Context.Provider value={value}>{children}</Context.Provider>;
};

/**
* React hook for accessing pre-wired services.
*/
export function useErrorBoundary(): KibanaErrorBoundaryServices {
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;
}
79 changes: 79 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,79 @@
/*
tsullivan marked this conversation as resolved.
Show resolved Hide resolved
* 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/;

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

/**
* Kibana Error Boundary Services: Error Service
* Each Error Boundary tracks an instance of this class
* @internal
*/
export class KibanaErrorService {
/**
* 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;
}

/**
* Creates a decorated error object
* TODO: capture telemetry
*/
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,
};
}
}
Loading