feat(client): refining error fallback for different components when catching errors (#4459)

* feat(client): refining error fallback for different components when catching errors

* fix: build

* refactor: `ErrorFallback.Inline` to `ErrorFallback.Modal`

* feat: toolbar error fallback

* chore: add deprecated comment

* fix: useSchemaToolbarRender
This commit is contained in:
YANG QIA 2024-05-28 13:26:38 +08:00 committed by GitHub
parent 9528da51be
commit 98a8e687b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 325 additions and 90 deletions

View File

@ -14,10 +14,11 @@ import React, { ComponentType, useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { useNiceDropdownMaxHeight } from '../../common/useNiceDropdownHeight';
import { useFlag } from '../../flag-provider';
import { useDesignable } from '../../schema-component';
import { ErrorFallback, useDesignable } from '../../schema-component';
import { useSchemaInitializerStyles } from './components/style';
import { SchemaInitializerContext } from './context';
import { SchemaInitializerOptions } from './types';
import { ErrorBoundary } from 'react-error-boundary';
const defaultWrap = (s: ISchema) => s;
@ -87,53 +88,55 @@ export function withInitializer<T>(C: ComponentType<T>) {
}
return (
<SchemaInitializerContext.Provider
value={{
visible,
setVisible,
insert: insertSchema,
options: props,
}}
>
{popover === false ? (
React.createElement(C, cProps)
) : (
<Popover
placement={'bottomLeft'}
{...popoverProps}
arrow={false}
overlayClassName={overlayClassName}
open={visible}
onOpenChange={setVisible}
content={wrapSSR(
<div
className={`${componentCls} ${hashId}`}
style={{
maxHeight: dropdownMaxHeight,
overflowY: 'auto',
}}
>
<ConfigProvider
theme={{
components: {
Menu: {
itemHeight: token.marginXL,
borderRadius: token.borderRadiusSM,
itemBorderRadius: token.borderRadiusSM,
subMenuItemBorderRadius: token.borderRadiusSM,
},
},
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={(err) => console.error(err)}>
<SchemaInitializerContext.Provider
value={{
visible,
setVisible,
insert: insertSchema,
options: props,
}}
>
{popover === false ? (
React.createElement(C, cProps)
) : (
<Popover
placement={'bottomLeft'}
{...popoverProps}
arrow={false}
overlayClassName={overlayClassName}
open={visible}
onOpenChange={setVisible}
content={wrapSSR(
<div
className={`${componentCls} ${hashId}`}
style={{
maxHeight: dropdownMaxHeight,
overflowY: 'auto',
}}
>
{children}
</ConfigProvider>
</div>,
)}
>
{React.createElement(C, cProps)}
</Popover>
)}
</SchemaInitializerContext.Provider>
<ConfigProvider
theme={{
components: {
Menu: {
itemHeight: token.marginXL,
borderRadius: token.borderRadiusSM,
itemBorderRadius: token.borderRadiusSM,
subMenuItemBorderRadius: token.borderRadiusSM,
},
},
}}
>
{children}
</ConfigProvider>
</div>,
)}
>
{React.createElement(C, cProps)}
</Popover>
)}
</SchemaInitializerContext.Provider>
</ErrorBoundary>
);
},
{ displayName: `WithInitializer(${C.displayName || C.name})` },

View File

@ -10,7 +10,7 @@
import React, { FC, memo, useEffect, useMemo, useRef } from 'react';
import { useFieldComponentName } from '../../../common/useFieldComponentName';
import { useFindComponent } from '../../../schema-component';
import { ErrorFallback, useFindComponent } from '../../../schema-component';
import {
SchemaSettingsActionModalItem,
SchemaSettingsCascaderItem,
@ -27,6 +27,7 @@ import {
} from '../../../schema-settings/SchemaSettings';
import { SchemaSettingItemContext } from '../context/SchemaSettingItemContext';
import { SchemaSettingsItemType } from '../types';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
export interface SchemaSettingsChildrenProps {
children: SchemaSettingsItemType[];
@ -46,6 +47,19 @@ const typeComponentMap = {
modal: SchemaSettingsModalItem,
};
const SchemaSettingsChildErrorFallback: FC<
FallbackProps & {
title: string;
}
> = (props) => {
const { title, ...fallbackProps } = props;
return (
<SchemaSettingsItem title={title}>
<ErrorFallback.Modal {...fallbackProps} />
</SchemaSettingsItem>
);
};
export const SchemaSettingsChildren: FC<SchemaSettingsChildrenProps> = (props) => {
const { children } = props;
const { visible } = useSchemaSettings();
@ -70,7 +84,15 @@ export const SchemaSettingsChildren: FC<SchemaSettingsChildrenProps> = (props) =
// 两次渲染之间 props 可能发生变化,就可能报 hooks 调用顺序的错误。所以这里使用 fieldComponentName 和 item.name 拼成
// 一个不会重复的 key保证每次渲染都是新的组件。
const key = `${fieldComponentName ? fieldComponentName + '-' : ''}${item.name}`;
return <SchemaSettingsChild key={key} {...item} />;
return (
<ErrorBoundary
key={key}
FallbackComponent={(props) => <SchemaSettingsChildErrorFallback {...props} title={key} />}
onError={(err) => console.log(err)}
>
<SchemaSettingsChild {...item} />
</ErrorBoundary>
);
})}
</>
);

View File

@ -9,14 +9,29 @@
import { ISchema } from '@formily/json-schema';
import React, { useMemo } from 'react';
import { useComponent, useDesignable } from '../../../schema-component';
import { ErrorFallback, useComponent, useDesignable } from '../../../schema-component';
import { SchemaToolbar, SchemaToolbarProps } from '../../../schema-settings/GeneralSchemaDesigner';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
const SchemaToolbarErrorFallback: React.FC<FallbackProps> = (props) => {
const { designable } = useDesignable();
if (!designable) {
return null;
}
return (
<ErrorFallback.Modal {...props}>
<SchemaToolbar title={`render toolbar error: ${props.error.message}`} />
</ErrorFallback.Modal>
);
};
export const useSchemaToolbarRender = (fieldSchema: ISchema) => {
const { designable } = useDesignable();
const toolbar = useMemo(() => {
if (fieldSchema['x-toolbar'] || fieldSchema['x-designer']) {
return fieldSchema['x-toolbar'] || fieldSchema['x-designer'];
if (fieldSchema['x-designer'] || fieldSchema['x-toolbar']) {
return fieldSchema['x-designer'] || fieldSchema['x-toolbar'];
}
if (fieldSchema['x-settings']) {
@ -30,7 +45,11 @@ export const useSchemaToolbarRender = (fieldSchema: ISchema) => {
if (!designable || !C) {
return null;
}
return <C {...fieldSchema['x-toolbar-props']} {...props} />;
return (
<ErrorBoundary FallbackComponent={SchemaToolbarErrorFallback} onError={(err) => console.error(err)}>
<C {...fieldSchema['x-toolbar-props']} {...props} />
</ErrorBoundary>
);
},
exists: !!C,
};

View File

@ -14,9 +14,10 @@ import { concat } from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useFormBlockContext } from '../../block-provider/FormBlockProvider';
import { useDynamicComponentProps } from '../../hoc/withDynamicSchemaProps';
import { useCompile, useComponent } from '../../schema-component';
import { ErrorFallback, useCompile, useComponent } from '../../schema-component';
import { useIsAllowToSetDefaultValue } from '../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { CollectionFieldProvider, useCollectionField } from './CollectionFieldProvider';
import { ErrorBoundary } from 'react-error-boundary';
type Props = {
component: any;
@ -96,9 +97,11 @@ export const CollectionFieldInternalField: React.FC = (props: Props) => {
export const CollectionField = connect((props) => {
const fieldSchema = useFieldSchema();
return (
<CollectionFieldProvider name={fieldSchema.name}>
<CollectionFieldInternalField {...props} />
</CollectionFieldProvider>
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={(err) => console.log(err)}>
<CollectionFieldProvider name={fieldSchema.name}>
<CollectionFieldInternalField {...props} />
</CollectionFieldProvider>
</ErrorBoundary>
);
});

View File

@ -11,18 +11,29 @@ import { observer, RecursionField, useField, useFieldSchema } from '@formily/rea
import { Drawer } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { OpenSize } from './types';
import { ActionDrawerProps, OpenSize } from './types';
import { useStyles } from './Action.Drawer.style';
import { useActionContext } from './hooks';
import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer';
import { ComposedActionDrawer } from './types';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback';
const DrawerErrorFallback: React.FC<FallbackProps> = (props) => {
const { visible, setVisible } = useActionContext();
return (
<Drawer open={visible} onClose={() => setVisible(false, true)} width="50%">
<ErrorFallback {...props} />
</Drawer>
);
};
const openSizeWidthMap = new Map<OpenSize, string>([
['small', '30%'],
['middle', '50%'],
['large', '70%'],
]);
export const ActionDrawer: ComposedActionDrawer = observer(
export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
(props) => {
const { footerNodeName = 'Action.Drawer.Footer', ...others } = props;
const { visible, setVisible, openSize = 'middle', drawerProps, modalProps } = useActionContext();
@ -83,6 +94,12 @@ export const ActionDrawer: ComposedActionDrawer = observer(
{ displayName: 'ActionDrawer' },
);
export const ActionDrawer: ComposedActionDrawer = (props) => (
<ErrorBoundary FallbackComponent={DrawerErrorFallback} onError={(err) => console.log(err)}>
<InternalActionDrawer {...props} />
</ErrorBoundary>
);
ActionDrawer.Footer = observer(
() => {
const field = useField();

View File

@ -14,15 +14,26 @@ import classNames from 'classnames';
import React from 'react';
import { useToken } from '../../../style';
import { useSetAriaLabelForModal } from './hooks/useSetAriaLabelForModal';
import { ComposedActionDrawer, OpenSize } from './types';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
import { useActionContext } from './hooks';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback';
const ModalErrorFallback: React.FC<FallbackProps> = (props) => {
const { visible, setVisible } = useActionContext();
return (
<Modal open={visible} onCancel={() => setVisible(false, true)} width="60%">
<ErrorFallback {...props} />
</Modal>
);
};
const openSizeWidthMap = new Map<OpenSize, string>([
['small', '40%'],
['middle', '60%'],
['large', '80%'],
]);
export const ActionModal: ComposedActionDrawer<ModalProps> = observer(
export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = observer(
(props) => {
const { footerNodeName = 'Action.Modal.Footer', width, ...others } = props;
const { visible, setVisible, openSize = 'middle', modalProps } = useActionContext();
@ -116,6 +127,12 @@ export const ActionModal: ComposedActionDrawer<ModalProps> = observer(
{ displayName: 'ActionModal' },
);
export const ActionModal: ComposedActionDrawer<ModalProps> = (props) => (
<ErrorBoundary FallbackComponent={ModalErrorFallback} onError={(err) => console.log(err)}>
<InternalActionModal {...props} />
</ErrorBoundary>
);
ActionModal.Footer = observer(
() => {
const field = useField();

View File

@ -14,7 +14,7 @@ import classnames from 'classnames';
import { default as lodash } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StablePopover, useActionContext } from '../..';
import { ErrorFallback, StablePopover, useActionContext } from '../..';
import { useDesignable } from '../../';
import { useACLActionParamsContext } from '../../../acl';
import {
@ -44,6 +44,9 @@ import { useA } from './hooks';
import { useGetAriaLabelOfAction } from './hooks/useGetAriaLabelOfAction';
import { ActionProps, ComposedAction } from './types';
import { linkageAction, setInitialActionState } from './utils';
import { ErrorBoundary } from 'react-error-boundary';
const handleError = (err) => console.log(err);
export const Action: ComposedAction = withDynamicSchemaProps(
observer((props: ActionProps) => {
@ -246,6 +249,11 @@ export const Action: ComposedAction = withDynamicSchemaProps(
Action.Popover = observer(
(props) => {
const { button, visible, setVisible } = useActionContext();
const content = (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={handleError}>
{props.children}
</ErrorBoundary>
);
return (
<StablePopover
{...props}
@ -254,7 +262,7 @@ Action.Popover = observer(
onOpenChange={(visible) => {
setVisible(visible);
}}
content={props.children}
content={content}
>
{button}
</StablePopover>

View File

@ -86,6 +86,8 @@ export type ComposedAction = React.FC<ActionProps> & {
[key: string]: any;
};
export type ComposedActionDrawer<T = DrawerProps> = React.FC<T & { footerNodeName?: string }> & {
export type ActionDrawerProps<T = DrawerProps> = T & { footerNodeName?: string };
export type ComposedActionDrawer<T = DrawerProps> = React.FC<ActionDrawerProps<T>> & {
Footer?: React.FC;
};

View File

@ -13,8 +13,11 @@ import React, { useMemo } from 'react';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { CustomCreateStylesUtils, createStyles } from '../../../style';
import { SortableItem } from '../../common';
import { useDesigner, useProps } from '../../hooks';
import { useProps } from '../../hooks';
import { useGetAriaLabelOfBlockItem } from './hooks/useGetAriaLabelOfBlockItem';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../error-fallback';
import { useSchemaToolbarRender } from '../../../application';
const useStyles = createStyles(({ css, token }: CustomCreateStylesUtils) => {
return css`
@ -79,16 +82,18 @@ export const BlockItem: React.FC<BlockItemProps> = withDynamicSchemaProps(
const { className, children } = useProps(props);
const { styles: blockItemCss } = useStyles();
const Designer = useDesigner();
const fieldSchema = useFieldSchema();
const { render } = useSchemaToolbarRender(fieldSchema);
const { getAriaLabel } = useGetAriaLabelOfBlockItem(props.name);
const label = useMemo(() => getAriaLabel(), [getAriaLabel]);
return (
<SortableItem role="button" aria-label={label} className={cls('nb-block-item', className, blockItemCss)}>
<Designer {...fieldSchema['x-toolbar-props']} />
{children}
{render()}
<ErrorBoundary FallbackComponent={ErrorFallback} onError={(err) => console.log(err)}>
{children}
</ErrorBoundary>
</SortableItem>
);
},

View File

@ -11,10 +11,13 @@ import { Button, Result, Typography } from 'antd';
import React, { FC } from 'react';
import { FallbackProps, useErrorBoundary } from 'react-error-boundary';
import { Trans, useTranslation } from 'react-i18next';
import { ErrorFallbackModal } from './ErrorFallbackModal';
const { Paragraph, Text, Link } = Typography;
export const ErrorFallback: FC<FallbackProps> = ({ error }) => {
export const ErrorFallback: FC<FallbackProps> & {
Modal: FC<FallbackProps>;
} = ({ error }) => {
const { resetBoundary } = useErrorBoundary();
const { t } = useTranslation();
@ -52,3 +55,5 @@ export const ErrorFallback: FC<FallbackProps> = ({ error }) => {
</div>
);
};
ErrorFallback.Modal = ErrorFallbackModal;

View File

@ -0,0 +1,50 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Modal } from 'antd';
import React, { FC } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { ErrorFallback } from './ErrorFallback';
import { Typography } from 'antd';
const { Paragraph, Text } = Typography;
export const ErrorFallbackModal: FC<FallbackProps> = (props) => {
const [open, setOpen] = React.useState(false);
const defaultChildren = (
<Paragraph
style={{
display: 'flex',
marginBottom: 0,
}}
copyable={{ text: props.error.message }}
>
<Text
type="danger"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
display: 'inline-block',
maxWidth: '200px',
}}
>
Error: {props.error.message}
</Text>
</Paragraph>
);
return (
<>
<div onMouseOver={() => setOpen(true)}>{props.children || defaultChildren}</div>
<Modal open={open} footer={null} onCancel={() => setOpen(false)} width={'60%'}>
<ErrorFallback {...props} />
</Modal>
</>
);
};

View File

@ -1,8 +1,6 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../ErrorFallback';
import { ErrorFallback } from '../../ErrorFallback';
const App = () => {
throw new Error('error message');

View File

@ -0,0 +1,24 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../../ErrorFallback';
const App = () => {
throw new Error('error message');
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -7,9 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { render, screen } from '@nocobase/test/client';
import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react';
import App1 from '../demos/demo1';
import App1 from './components/basic';
import InlineApp from './components/modal';
describe('ErrorFallback', () => {
it('should render correctly', () => {
@ -24,4 +25,19 @@ describe('ErrorFallback', () => {
// 底部复制按钮
expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument();
});
it('should render inline correctly', async () => {
render(<InlineApp />);
expect(screen.getByText(/Error: error message/i)).toBeInTheDocument();
expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument();
await userEvent.hover(screen.getByText(/Error: error message/i));
await waitFor(() => {
expect(screen.getByText(/render failed/i)).toBeInTheDocument();
expect(
screen.getByText(/this is likely a nocobase internals bug\. please open an issue at/i),
).toBeInTheDocument();
});
});
});

View File

@ -1,5 +1,3 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '@nocobase/client';

View File

@ -0,0 +1,26 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '@nocobase/client';
import { Button } from 'antd';
const App = () => {
const [showError, setShowError] = React.useState(false);
if (showError) {
throw new Error('error message');
}
return (
<Button danger onClick={() => setShowError(true)}>
show error
</Button>
);
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback.Modal} onError={console.error}>
<App />
</ErrorBoundary>
);
};

View File

@ -2,6 +2,12 @@
The component displayed when an error occurs during rendering.
其基于 [react-error-boundary](https://github.com/bvaughn/react-error-boundary) 库。
Based on the [react-error-boundary](https://github.com/bvaughn/react-error-boundary) library.
## Basic
<code src="./demos/new-demos/basic.tsx"></code>
## Modal
<code src="./demos/new-demos/modal.tsx"></code>

View File

@ -6,4 +6,10 @@
其基于 [react-error-boundary](https://github.com/bvaughn/react-error-boundary) 库。
## Basic
<code src="./demos/new-demos/basic.tsx"></code>
## Modal
<code src="./demos/new-demos/modal.tsx"></code>

View File

@ -14,6 +14,10 @@ import { SchemaToolbar } from '../../schema-settings';
const DefaultSchemaToolbar = () => null;
/**
* @deprecated
* use `useSchemaToolbarRender` instead
*/
export const useDesigner = () => {
const { designable } = useDesignable();
const fieldSchema = useFieldSchema();

View File

@ -200,7 +200,10 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
showBackground,
showBorder = true,
draggable = true,
} = { ...props, ...(fieldSchema['x-toolbar-props'] || {}) } as SchemaToolbarProps;
} = {
...props,
...(fieldSchema['x-toolbar-props'] || {}),
} as SchemaToolbarProps;
const { designable } = useDesignable();
const compile = useCompile();
const { styles } = useStyles();
@ -271,7 +274,14 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
useEffect(() => {
const toolbarElement = toolbarRef.current;
const parentElement = toolbarElement?.parentElement;
let parentElement = toolbarElement?.parentElement;
while (parentElement && window.getComputedStyle(parentElement).height === '0px') {
parentElement = parentElement.parentElement;
}
if (!parentElement) {
return;
}
function show() {
if (toolbarElement) {
toolbarElement.style.display = 'block';
@ -284,21 +294,17 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
}
}
if (parentElement) {
const style = window.getComputedStyle(parentElement);
if (style.position === 'static') {
parentElement.style.position = 'relative';
}
parentElement.addEventListener('mouseenter', show);
parentElement.addEventListener('mouseleave', hide);
const style = window.getComputedStyle(parentElement);
if (style.position === 'static') {
parentElement.style.position = 'relative';
}
parentElement.addEventListener('mouseenter', show);
parentElement.addEventListener('mouseleave', hide);
return () => {
if (parentElement) {
parentElement.removeEventListener('mouseenter', show);
parentElement.removeEventListener('mouseleave', hide);
}
parentElement.removeEventListener('mouseenter', show);
parentElement.removeEventListener('mouseleave', hide);
};
}, []);