Skip to content

Commit

Permalink
feature: ErrorBoundary 사용 방식 변경 및 간소화 (#477)
Browse files Browse the repository at this point in the history
* refactor: 불필요한 stringify 제거

* refactor: APIError status 타입 지정

* refactor: shouldIgnore 파라미터 타입을 제네릭으로 변경

* feat: BaseFunction type 추가

* feat: resolveFunctionOrPrimitive 유틸 함수 추가

* refactor: APIBoundary onError > ignore로 변경

* refactor: APIError의 method가 get이 아니라면 ignore true로 설정

* refactor: Error field 수정

- ignore > IGNORE_KEY

* refactor: ignore 인터페이스 수정

- boolean도 사용할 수 있도록 변경

* refactor: ErrorBoundary 인터페이스 수정

- mustCatch 추가
- ignore > shouldIgnore로 변경
- EndOfBoundary > CriticalBoundary로 변경
- 기존 shouldIgnore 유틸 함수 > isIgnored로 변경

* chore: nvmrc 추가 v18.16.1

* feat: Loading 컴포넌트 추가

* refactor: 기존 QueryBoundary 사용을 Loading, Suspense로 변경
  • Loading branch information
n0eyes authored Oct 12, 2023
1 parent 38808a1 commit 7a7d415
Show file tree
Hide file tree
Showing 14 changed files with 98 additions and 98 deletions.
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

0 comments on commit 7a7d415

Please sign in to comment.