From 7a7d415bae62783bcead88d796d26701bc48db8b Mon Sep 17 00:00:00 2001 From: Seyeon Jeong <79056677+n0eyes@users.noreply.github.com> Date: Thu, 12 Oct 2023 16:40:18 +0900 Subject: [PATCH] =?UTF-8?q?feature:=20ErrorBoundary=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94=20(#477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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로 변경 --- frontend/.nvmrc | 2 + frontend/.storybook/preview.tsx | 5 +- frontend/src/App.tsx | 8 +-- .../ErrorBoundary/APIBoundary/APIBoundary.tsx | 17 ++---- .../@common/ErrorBoundary/ErrorBoundary.tsx | 59 +++++++------------ .../QueryBoundary/QueryBoundary.tsx | 2 +- .../components/@common/Loading/Loading.tsx | 9 +++ .../{ => LoadingSpinner}/LoadingSpinner.tsx | 0 .../FilterBottomSheet/FilterBottomSheet.tsx | 37 ++++++------ .../PetProfile/PetListBottomSheet.tsx | 6 +- .../ReviewList/SummaryChart/SummaryChart.tsx | 7 +-- frontend/src/types/common/utility.ts | 16 +++-- frontend/src/utils/compound.ts | 10 +++- frontend/src/utils/errors.ts | 18 +++--- 14 files changed, 98 insertions(+), 98 deletions(-) create mode 100644 frontend/.nvmrc create mode 100644 frontend/src/components/@common/Loading/Loading.tsx rename frontend/src/components/@common/{ => LoadingSpinner}/LoadingSpinner.tsx (100%) diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 000000000..73ff83260 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1,2 @@ +v18.16.1 + diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 97dbdb1e4..8e4f4c344 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -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 = {}; @@ -96,9 +97,9 @@ const preview: Preview = { - Loading...}> + - + ), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c626d92d9..2c0101416 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -17,7 +17,7 @@ import { UnexpectedError } from './utils/errors'; import { setScreenSize } from './utils/setScreenSize'; const errorFallback = ({ reset, error }: ErrorBoundaryValue) => ( - + ); const queryClient = new QueryClient({ @@ -36,14 +36,14 @@ const App = () => ( - + - + ); diff --git a/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx index f046e7d0b..b7621ae97 100644 --- a/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx +++ b/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx @@ -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>; const APIBoundary = (props: PropsWithChildren) => { - 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 추후 에러 코드 상의 후 변경 */ @@ -30,8 +24,9 @@ const APIBoundary = (props: PropsWithChildren) => { ); return ( + /** @description ignore는 사용하는 쪽에서 재정의 할 수 있다 */ - onError={composeFunctions(handleAPIError, onError)} + shouldIgnore={handleIgnore} fallback={handleAPIFallback} {...restProps} /> diff --git a/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx index 2a5820135..f799beeb3 100644 --- a/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx +++ b/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx @@ -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 = { @@ -14,9 +13,10 @@ const initialState = { } as const; interface ErrorBoundaryProps { + mustCatch?: boolean; resetKeys?: unknown[]; fallback?: ReactNode | RenderProps>; - ignore?(props: E): boolean; + shouldIgnore?: boolean | ((payload: { error: E }) => boolean); onError?(payload: { error: E; errorInfo: ErrorInfo }): void; onReset?: VoidFunction; } @@ -33,8 +33,6 @@ class BaseErrorBoundary extends Component< } static getDerivedStateFromError(error: Error) { - if (shouldIgnore(error)) throw error; - return { hasError: true, error: ZipgoError.convertToError(error), @@ -47,12 +45,16 @@ class BaseErrorBoundary 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 }); } @@ -72,37 +74,20 @@ class BaseErrorBoundary extends Component< if (!hasError) return children; - if (!fallback) return null; - return resolveRenderProps(fallback, { reset: this.reset, error }); } } -class BaseEndOfErrorBoundary extends BaseErrorBoundary { - static getDerivedStateFromError(error: Error) { - return { - hasError: true, - error: ZipgoError.convertToError(error), - }; - } -} - -const resetOnNavigate = (ErrorBoundary: typeof BaseErrorBoundary) => { - const ErrorBoundaryWithResetKeys = ( - props: ComponentProps>, - ) => { - const location = useLocation(); +const ErrorBoundary = (props: PropsWithChildren>) => { + const location = useLocation(); - const resetKeys = [location.key, ...(('resetKeys' in props && props.resetKeys) || [])]; + const resetKeys = [location.key, ...(props.resetKeys || [])]; - return {...props} resetKeys={resetKeys} />; - }; - - return ErrorBoundaryWithResetKeys; + return {...props} resetKeys={resetKeys} />; }; -const ErrorBoundary = resetOnNavigate(BaseErrorBoundary); - -const EndOfErrorBoundary = resetOnNavigate(BaseEndOfErrorBoundary); +const CriticalBoundary = (props: PropsWithChildren>) => ( + {...props} mustCatch /> +); -export { EndOfErrorBoundary, ErrorBoundary }; +export { CriticalBoundary, ErrorBoundary }; diff --git a/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx index ff3eb50b6..8141fe8a8 100644 --- a/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx +++ b/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx @@ -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, 'fallback'> { diff --git a/frontend/src/components/@common/Loading/Loading.tsx b/frontend/src/components/@common/Loading/Loading.tsx new file mode 100644 index 000000000..bf677a852 --- /dev/null +++ b/frontend/src/components/@common/Loading/Loading.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren, Suspense } from 'react'; + +import LoadingSpinner from '../LoadingSpinner/LoadingSpinner'; + +const Loading = ({ children }: PropsWithChildren) => ( + }>{children} +); + +export default Loading; diff --git a/frontend/src/components/@common/LoadingSpinner.tsx b/frontend/src/components/@common/LoadingSpinner/LoadingSpinner.tsx similarity index 100% rename from frontend/src/components/@common/LoadingSpinner.tsx rename to frontend/src/components/@common/LoadingSpinner/LoadingSpinner.tsx diff --git a/frontend/src/components/Food/FilterBottomSheet/FilterBottomSheet.tsx b/frontend/src/components/Food/FilterBottomSheet/FilterBottomSheet.tsx index 7dbecddec..a4b6be56a 100644 --- a/frontend/src/components/Food/FilterBottomSheet/FilterBottomSheet.tsx +++ b/frontend/src/components/Food/FilterBottomSheet/FilterBottomSheet.tsx @@ -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'; @@ -20,25 +20,22 @@ import NutritionStandardsFilterList from './NutritionStandardsFilterList/Nutriti const FilterBottomSheet = () => ( - - - - - - 필터 - - - - - - - {({ openHandler }) => } - - - - - - + + + + + 필터 + + + + + + + {({ openHandler }) => } + + + + ); diff --git a/frontend/src/components/PetProfile/PetListBottomSheet.tsx b/frontend/src/components/PetProfile/PetListBottomSheet.tsx index 500dddc74..4f89dcdaa 100644 --- a/frontend/src/components/PetProfile/PetListBottomSheet.tsx +++ b/frontend/src/components/PetProfile/PetListBottomSheet.tsx @@ -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 = () => ( - + {({ openHandler }) => } - + ); diff --git a/frontend/src/components/Review/ReviewList/SummaryChart/SummaryChart.tsx b/frontend/src/components/Review/ReviewList/SummaryChart/SummaryChart.tsx index acc5e5dfd..dc49a2a81 100644 --- a/frontend/src/components/Review/ReviewList/SummaryChart/SummaryChart.tsx +++ b/frontend/src/components/Review/ReviewList/SummaryChart/SummaryChart.tsx @@ -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'; @@ -31,9 +30,9 @@ const SummaryChart = () => { {summaryKeywords.map(keyword => ( - }> + }> - + ))} diff --git a/frontend/src/types/common/utility.ts b/frontend/src/types/common/utility.ts index d51b0e037..6cc26457e 100644 --- a/frontend/src/types/common/utility.ts +++ b/frontend/src/types/common/utility.ts @@ -1,14 +1,16 @@ import { ReactNode } from 'react'; -export type RenderProps

= (payload: P) => ReactNode; +type RenderProps

= (payload: P) => ReactNode; -export type Unpack = T extends (infer U)[] ? U : T extends Set ? U : T; +type Unpack = T extends (infer U)[] ? U : T extends Set ? U : T; -export type Parameter unknown> = Parameters[0]; +type Parameter unknown> = Parameters[0]; -export type StyledProps = { [K in keyof T as `$${string & K}`]: T[K] }; +type StyledProps = { [K in keyof T as `$${string & K}`]: T[K] }; -export type Values = T[keyof T]; +type Values = T[keyof T]; + +type BaseFunction = (...args: never[]) => unknown; type Separator = '_'; @@ -24,7 +26,9 @@ type Replace = IsCapitalized extends true ? `${Separator}${Lowercase}` : Char; -export type CamelToSnake< +type CamelToSnake< Str extends string, Acc extends string = '', > = Str extends `${infer Char}${infer Rest}` ? CamelToSnake}`> : Acc; + +export type { BaseFunction, CamelToSnake, Parameter, RenderProps, StyledProps, Unpack, Values }; diff --git a/frontend/src/utils/compound.ts b/frontend/src/utils/compound.ts index 6dc538f68..18b3be51a 100644 --- a/frontend/src/utils/compound.ts +++ b/frontend/src/utils/compound.ts @@ -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; @@ -54,4 +54,10 @@ export const getValidProps = ( export const resolveRenderProps =

( renderProps: ReactNode | RenderProps

, payload: P, -) => (typeof renderProps === 'function' ? renderProps(payload) : renderProps); +) => resolveFunctionOrPrimitive(renderProps, payload); + +export const resolveFunctionOrPrimitive = ( + functionOrPrimitive: T, + ...payload: T extends BaseFunction ? Parameters : never[] +): T extends BaseFunction ? ReturnType : T => + typeof functionOrPrimitive === 'function' ? functionOrPrimitive(...payload) : functionOrPrimitive; diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts index a706f077a..b4686b74d 100644 --- a/frontend/src/utils/errors.ts +++ b/frontend/src/utils/errors.ts @@ -40,7 +40,7 @@ class ZipgoError extends Error { this.cause = options.cause; - this.ignore = false; + this[IGNORE_KEY] = false; } static convertToError(error: unknown) { @@ -56,9 +56,9 @@ class ZipgoError extends Error { class RuntimeError extends ZipgoError { constructor(info: ErrorInfo, value?: unknown) { - super(info, JSON.stringify(value)); + super(info, value); - this.ignore = true; + this[IGNORE_KEY] = true; } } @@ -66,12 +66,12 @@ class UnexpectedError extends ZipgoError<'UNEXPECTED_ERROR'> { constructor(value?: unknown) { super({ code: 'UNEXPECTED_ERROR' }, value); - this.ignore = true; + this[IGNORE_KEY] = true; } } class APIError extends ZipgoError { - status; + status: number; constructor(error: ManageableAxiosError, D>>) { /** @description 서버의 코드 미제공 방지 */ @@ -80,6 +80,8 @@ class APIError extends ZipgoError { super({ code }); this.status = error.response.status; + + this[IGNORE_KEY] = error.config.method !== 'get'; } /** @@ -101,10 +103,10 @@ const createErrorParams = ( return [message, options]; }; -const shouldIgnore = (error: Error, ignoreKey = IGNORE_KEY) => +const isIgnored = (error: E, ignoreKey = IGNORE_KEY) => Object.prototype.hasOwnProperty.call(error, ignoreKey) && - (error as Error & { [key in typeof ignoreKey]: boolean })[ignoreKey]; + (error as E & { [key in typeof ignoreKey]: boolean })[ignoreKey]; -export { APIError, RuntimeError, shouldIgnore, UnexpectedError, ZipgoError }; +export { APIError, isIgnored, RuntimeError, UnexpectedError, ZipgoError }; export type { ManageableAxiosError };