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

feature: ErrorBoundary 사용 방식 변경 및 간소화 #477

Merged
merged 13 commits into from
Oct 12, 2023
Merged
2 changes: 2 additions & 0 deletions frontend/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
v18.16.1

5 changes: 3 additions & 2 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { withRouter } from 'storybook-addon-react-router-v6';
import handlers from '../src/mocks/handlers';
import Loading from '../src/components/@common/Loading/Loading';

let options = {};

Expand Down Expand Up @@ -96,9 +97,9 @@ const preview: Preview = {
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<GlobalStyle />
<Suspense fallback={<div>Loading...</div>}>
<Loading>
<Story />
</Suspense>
</Loading>
</ThemeProvider>
</QueryClientProvider>
),
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';

import { EndOfErrorBoundary } from './components/@common/ErrorBoundary/ErrorBoundary';
import { CriticalBoundary } from './components/@common/ErrorBoundary/ErrorBoundary';
import QueryBoundary from './components/@common/ErrorBoundary/QueryBoundary/QueryBoundary';
import GlobalStyle from './components/@common/GlobalStyle';
import { ERROR_MESSAGE_KIT } from './constants/errors';
Expand All @@ -17,7 +17,7 @@ import { UnexpectedError } from './utils/errors';
import { setScreenSize } from './utils/setScreenSize';

const errorFallback = ({ reset, error }: ErrorBoundaryValue) => (
<ErrorPage reset={reset} error={error} />
<ErrorPage reset={reset} error={error} refresh />
);

const queryClient = new QueryClient({
Expand All @@ -36,14 +36,14 @@ const App = () => (
<ThemeProvider theme={theme}>
<GlobalStyle />
<QueryClientProvider client={queryClient}>
<EndOfErrorBoundary fallback={errorFallback}>
<CriticalBoundary fallback={errorFallback}>
<QueryBoundary errorFallback={errorFallback}>
<ToastProvider>
<GlobalEvent />
<Outlet />
</ToastProvider>
</QueryBoundary>
</EndOfErrorBoundary>
</CriticalBoundary>
</QueryClientProvider>
</ThemeProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,17 @@ import { Navigate } from 'react-router-dom';

import { PATH } from '@/router/routes';
import { resolveRenderProps } from '@/utils/compound';
import { composeFunctions } from '@/utils/dom';
import { APIError, UnexpectedError } from '@/utils/errors';
import { APIError } from '@/utils/errors';

import { ErrorBoundary } from '../ErrorBoundary';

type APIBoundaryProps = ComponentProps<typeof ErrorBoundary<APIError>>;

const APIBoundary = (props: PropsWithChildren<APIBoundaryProps>) => {
const { fallback, onError, ...restProps } = props;
const { fallback, ...restProps } = props;

const handleAPIError: APIBoundaryProps['onError'] = ({
error,
}: {
error: APIError | UnexpectedError;
}) => {
if (!(error instanceof APIError)) throw error;
};
const handleIgnore: APIBoundaryProps['shouldIgnore'] = ({ error }) =>
!(error instanceof APIError);

const handleAPIFallback: APIBoundaryProps['fallback'] = ({ error, reset }) =>
/** @todo 추후 에러 코드 상의 후 변경 */
Expand All @@ -30,8 +24,9 @@ const APIBoundary = (props: PropsWithChildren<APIBoundaryProps>) => {
);

return (
/** @description ignore는 사용하는 쪽에서 재정의 할 수 있다 */
<ErrorBoundary<APIError>
onError={composeFunctions(handleAPIError, onError)}
shouldIgnore={handleIgnore}
fallback={handleAPIFallback}
{...restProps}
/>
Expand Down
59 changes: 22 additions & 37 deletions frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/* eslint-disable max-classes-per-file */
import { Component, ComponentProps, ErrorInfo, PropsWithChildren, ReactNode } from 'react';
import { Component, ErrorInfo, PropsWithChildren, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';

import { ErrorBoundaryState, ErrorBoundaryValue } from '@/types/common/errorBoundary';
import { RenderProps } from '@/types/common/utility';
import { resolveRenderProps } from '@/utils/compound';
import { shouldIgnore, ZipgoError } from '@/utils/errors';
import { resolveFunctionOrPrimitive, resolveRenderProps } from '@/utils/compound';
import { isIgnored, ZipgoError } from '@/utils/errors';
import { isDifferentArray } from '@/utils/isDifferentArray';

const initialState = {
Expand All @@ -14,9 +13,10 @@ const initialState = {
} as const;

interface ErrorBoundaryProps<E extends Error> {
mustCatch?: boolean;
resetKeys?: unknown[];
fallback?: ReactNode | RenderProps<ErrorBoundaryValue<E>>;
ignore?<E extends Error>(props: E): boolean;
shouldIgnore?: boolean | ((payload: { error: E }) => boolean);
onError?(payload: { error: E; errorInfo: ErrorInfo }): void;
onReset?: VoidFunction;
}
Expand All @@ -33,8 +33,6 @@ class BaseErrorBoundary<E extends Error = Error> extends Component<
}

static getDerivedStateFromError(error: Error) {
if (shouldIgnore(error)) throw error;

return {
hasError: true,
error: ZipgoError.convertToError(error),
Expand All @@ -47,12 +45,16 @@ class BaseErrorBoundary<E extends Error = Error> extends Component<
}
}

componentDidCatch(error: E, errorInfo: ErrorInfo): void {
const { onError, ignore } = this.props;
componentDidCatch(_: E, errorInfo: ErrorInfo): void {
const { onError, shouldIgnore, mustCatch } = this.props;

if (ignore?.(error)) {
throw error;
}
const { error, hasError } = this.state;

if (!hasError) return;

const willIgnore = isIgnored(error) || resolveFunctionOrPrimitive(shouldIgnore, { error });

if (!mustCatch && willIgnore) throw error;

onError?.({ error, errorInfo });
}
Expand All @@ -72,37 +74,20 @@ class BaseErrorBoundary<E extends Error = Error> extends Component<

if (!hasError) return children;

if (!fallback) return null;

return resolveRenderProps(fallback, { reset: this.reset, error });
}
}

class BaseEndOfErrorBoundary<E extends Error = Error> extends BaseErrorBoundary<E> {
static getDerivedStateFromError(error: Error) {
return {
hasError: true,
error: ZipgoError.convertToError(error),
};
}
}

const resetOnNavigate = (ErrorBoundary: typeof BaseErrorBoundary) => {
const ErrorBoundaryWithResetKeys = <E extends Error>(
props: ComponentProps<typeof ErrorBoundary<E>>,
) => {
const location = useLocation();
const ErrorBoundary = <E extends Error>(props: PropsWithChildren<ErrorBoundaryProps<E>>) => {
const location = useLocation();

const resetKeys = [location.key, ...(('resetKeys' in props && props.resetKeys) || [])];
const resetKeys = [location.key, ...(props.resetKeys || [])];

return <ErrorBoundary<E> {...props} resetKeys={resetKeys} />;
};

return ErrorBoundaryWithResetKeys;
return <BaseErrorBoundary<E> {...props} resetKeys={resetKeys} />;
};

const ErrorBoundary = resetOnNavigate(BaseErrorBoundary);

const EndOfErrorBoundary = resetOnNavigate(BaseEndOfErrorBoundary);
const CriticalBoundary = <E extends Error>(props: PropsWithChildren<ErrorBoundaryProps<E>>) => (
<ErrorBoundary<E> {...props} mustCatch />
);

export { EndOfErrorBoundary, ErrorBoundary };
export { CriticalBoundary, ErrorBoundary };
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ComponentProps, PropsWithChildren, Suspense } from 'react';

import { composeFunctions } from '@/utils/dom';

import LoadingSpinner from '../../LoadingSpinner';
import LoadingSpinner from '../../LoadingSpinner/LoadingSpinner';
import APIBoundary from '../APIBoundary/APIBoundary';

interface QueryBoundaryProps extends Omit<ComponentProps<typeof APIBoundary>, 'fallback'> {
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/@common/Loading/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PropsWithChildren, Suspense } from 'react';

import LoadingSpinner from '../LoadingSpinner/LoadingSpinner';

const Loading = ({ children }: PropsWithChildren) => (
<Suspense fallback={<LoadingSpinner />}>{children}</Suspense>
);

export default Loading;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import styled, { css } from 'styled-components';

import SettingsIcon from '@/assets/svg/settings_outline_icon.svg';
import { Dialog } from '@/components/@common/Dialog/Dialog';
import QueryBoundary from '@/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary';
import Loading from '@/components/@common/Loading/Loading';
import Tabs from '@/components/@common/Tabs/Tabs';
import { FoodFilterProvider, useFoodFilterContext } from '@/context/food';
import { useFoodListFilterMetaQuery } from '@/hooks/query/food';
Expand All @@ -20,25 +20,22 @@ import NutritionStandardsFilterList from './NutritionStandardsFilterList/Nutriti

const FilterBottomSheet = () => (
<FoodFilterProvider>
<FilterDialogAndFilterDisplayContainer>
<Dialog>
<Dialog.Trigger asChild>
<DialogTrigger type="button">
<FilterTriggerIcon src={SettingsIcon} alt="필터 버튼 아이콘" />
<span>필터</span>
</DialogTrigger>
</Dialog.Trigger>
<Dialog.Portal>
<QueryBoundary>
<Dialog.BackDrop />
<Dialog.Content asChild>
{({ openHandler }) => <KeywordContent toggleDialog={openHandler} />}
</Dialog.Content>
</QueryBoundary>
</Dialog.Portal>
</Dialog>
<FilterSelectionDisplay />
</FilterDialogAndFilterDisplayContainer>
<Dialog>
<Dialog.Trigger asChild>
<DialogTrigger type="button">
<FilterTriggerIcon src={SettingsIcon} alt="필터 버튼 아이콘" />
<span>필터</span>
</DialogTrigger>
</Dialog.Trigger>
<Dialog.Portal>
<Loading>
<Dialog.BackDrop />
<Dialog.Content asChild>
{({ openHandler }) => <KeywordContent toggleDialog={openHandler} />}
</Dialog.Content>
</Loading>
</Dialog.Portal>
</Dialog>
</FoodFilterProvider>
);

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/PetProfile/PetListBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { styled } from 'styled-components';

import { Dialog } from '../@common/Dialog/Dialog';
import QueryBoundary from '../@common/ErrorBoundary/QueryBoundary/QueryBoundary';
import UserProfile from '../@common/Header/UserProfile';
import Loading from '../@common/Loading/Loading';
import PetList from './PetList';

const PetListBottomSheet = () => (
<Dialog>
<UserProfile />
<Dialog.Portal>
<QueryBoundary>
<Loading>
<Dialog.BackDrop />
<Dialog.Content asChild>
{({ openHandler }) => <PetListContainer toggleDialog={openHandler} />}
</Dialog.Content>
</QueryBoundary>
</Loading>
</Dialog.Portal>
</Dialog>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, Suspense } from 'react';
import styled, { css } from 'styled-components';

import QueryBoundary from '@/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary';
import StarRatingDisplay from '@/components/@common/StarRating/StarRatingDisplay/StartRatingDisplay';
import Tabs from '@/components/@common/Tabs/Tabs';
import { REVIEW_SUMMARY_KEYWORDS } from '@/constants/review';
Expand Down Expand Up @@ -31,9 +30,9 @@ const SummaryChart = () => {
{summaryKeywords.map(keyword => (
<Tabs.Content key={keyword} value={keyword} asChild>
<SummaryChartContentWrapper>
<QueryBoundary loadingFallback={<SummaryChartContent.Skeleton />}>
<Suspense fallback={<SummaryChartContent.Skeleton />}>
<SummaryChartContent keyword={keyword} />
</QueryBoundary>
</Suspense>
</SummaryChartContentWrapper>
</Tabs.Content>
))}
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/types/common/utility.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { ReactNode } from 'react';

export type RenderProps<P extends object = object> = (payload: P) => ReactNode;
type RenderProps<P extends object = object> = (payload: P) => ReactNode;

export type Unpack<T> = T extends (infer U)[] ? U : T extends Set<infer U> ? U : T;
type Unpack<T> = T extends (infer U)[] ? U : T extends Set<infer U> ? U : T;

export type Parameter<T extends (arg: never) => unknown> = Parameters<T>[0];
type Parameter<T extends (arg: never) => unknown> = Parameters<T>[0];

export type StyledProps<T> = { [K in keyof T as `$${string & K}`]: T[K] };
type StyledProps<T> = { [K in keyof T as `$${string & K}`]: T[K] };

export type Values<T extends object> = T[keyof T];
type Values<T extends object> = T[keyof T];

type BaseFunction = (...args: never[]) => unknown;

type Separator = '_';

Expand All @@ -24,7 +26,9 @@ type Replace<Char extends string> = IsCapitalized<Char> extends true
? `${Separator}${Lowercase<Char>}`
: Char;

export type CamelToSnake<
type CamelToSnake<
Str extends string,
Acc extends string = '',
> = Str extends `${infer Char}${infer Rest}` ? CamelToSnake<Rest, `${Acc}${Replace<Char>}`> : Acc;

export type { BaseFunction, CamelToSnake, Parameter, RenderProps, StyledProps, Unpack, Values };
10 changes: 8 additions & 2 deletions frontend/src/utils/compound.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Children, isValidElement, PropsWithChildren, ReactElement, ReactNode } from 'react';

import { RenderProps } from '@/types/common/utility';
import type { BaseFunction, RenderProps } from '@/types/common/utility';

export type AsChild = {
asChild?: boolean;
Expand Down Expand Up @@ -54,4 +54,10 @@ export const getValidProps = <P, R extends object>(
export const resolveRenderProps = <P extends object>(
renderProps: ReactNode | RenderProps<P>,
payload: P,
) => (typeof renderProps === 'function' ? renderProps(payload) : renderProps);
) => resolveFunctionOrPrimitive(renderProps, payload);

export const resolveFunctionOrPrimitive = <T>(
functionOrPrimitive: T,
...payload: T extends BaseFunction ? Parameters<T> : never[]
): T extends BaseFunction ? ReturnType<T> : T =>
typeof functionOrPrimitive === 'function' ? functionOrPrimitive(...payload) : functionOrPrimitive;
Loading