Skip to content

Commit

Permalink
Merge pull request #568 from woowacourse-teams/feat/540-improved-erro…
Browse files Browse the repository at this point in the history
…r-handling

에러 핸들링을 위한 컴포넌트 추가 및 적용
  • Loading branch information
solo5star authored Oct 11, 2023
2 parents c08ccf4 + c5bbb90 commit 8564562
Show file tree
Hide file tree
Showing 17 changed files with 483 additions and 146 deletions.
1 change: 1 addition & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
retry: false,
},
},
});
Expand Down
87 changes: 51 additions & 36 deletions client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { AuthProvider, AuthUrl, Cafe, CafeMapLocation, CafeMenu, LikedCafe, MapBounds, Rank, SearchedCafe, User } from './types';

export class ClientNetworkError extends Error {
constructor() {
super('인터넷에 연결할 수 없습니다');
}
}
import APIError from './errors/APIError';
import NetworkError from './errors/NetworkError';
import type {
AuthProvider,
AuthUrl,
Cafe,
CafeMapLocation,
CafeMenu,
LikedCafe,
MapBounds,
Rank,
SearchedCafe,
User,
} from './types';

class Client {
isAccessTokenRefreshing = false;
Expand All @@ -20,40 +27,48 @@ class Client {
}

async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const fetchFn: () => Promise<Response> = () =>
fetch(`/api${input}`, {
...init,
headers: {
...init?.headers,
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
},
});
let response: Response;

try {
const fetchFn = () =>
fetch(`/api${input}`, {
...init,
headers: {
...init?.headers,
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
},
});

let response = await fetchFn();
if (!response.ok) {
if (response.status === 401 && !this.isAccessTokenRefreshing) {
// this.refreshAccessToken() 을 호출하기 전,
// this.isAccessTokenRefreshing을 true로 설정해줘야 잠재적인 recursion loop를 방지할 수 있습니다.
this.isAccessTokenRefreshing = true;
const accessToken = await this.refreshAccessToken();
this.accessToken = accessToken;
this.accessTokenRefreshListener?.(accessToken);
this.isAccessTokenRefreshing = false;

response = await fetchFn();
}
response = await fetchFn();
} catch (error) {
throw new NetworkError();
}

if (!response.ok) {
if (response.status === 401 && !this.isAccessTokenRefreshing) {
// this.refreshAccessToken() 을 호출하기 전,
// this.isAccessTokenRefreshing을 true로 설정해줘야 잠재적인 recursion loop를 방지할 수 있습니다.
this.isAccessTokenRefreshing = true;
const accessToken = await this.refreshAccessToken();
this.accessToken = accessToken;
this.accessTokenRefreshListener?.(accessToken);
this.isAccessTokenRefreshing = false;

response = await fetchFn();
}

if (!response.ok) {
// access token 재발급이 불가하기 때문에 access token을 삭제한다.
this.accessTokenRefreshListener?.(null);
throw response;
if (!response.ok) {
// access token 재발급이 불가하기 때문에 access token을 삭제한다.
this.accessTokenRefreshListener?.(null);
let body: unknown;
try {
body = await response.json();
} catch (error) {
body = await response.text();
}
throw new APIError(response, body);
}
return response;
} catch (error) {
throw new ClientNetworkError();
}
return response;
}

async fetchJson<TData>(input: RequestInfo | URL, init?: RequestInit): Promise<TData> {
Expand Down
9 changes: 6 additions & 3 deletions client/src/components/CafeDetailBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Theme } from '../styles/theme';
import type { Cafe } from '../types';
import CafeMenuMiniList from './CafeMenuMiniList';
import OpeningHoursDetail from './OpeningHoursDetail';
import QueryErrorBoundary from './QueryErrorBoundary';

type CafeDetailBottomSheetProps = {
cafe: Cafe;
Expand Down Expand Up @@ -34,9 +35,11 @@ const CafeDetailBottomSheet = (props: CafeDetailBottomSheetProps) => {
</CloseButton>
<Title>{cafe.name}</Title>
<Spacer $size={'4'} />
<Suspense>
<CafeMenu cafeId={cafe.id} />
</Suspense>
<QueryErrorBoundary>
<Suspense>
<CafeMenu cafeId={cafe.id} />
</Suspense>
</QueryErrorBoundary>
<InfoContainer>
<LocationDetail>
<NaverMapIcon />
Expand Down
102 changes: 60 additions & 42 deletions client/src/components/CafeMenuBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { BsX } from 'react-icons/bs';
import { styled } from 'styled-components';
Expand All @@ -9,6 +9,7 @@ import type { Cafe } from '../types';
import Resource from '../utils/Resource';
import CafeMenuList from './CafeMenuList';
import ImageModal from './ImageModal';
import QueryErrorBoundary from './QueryErrorBoundary';

type CafeMenuBottomSheetProps = {
cafe: Cafe;
Expand All @@ -17,10 +18,6 @@ type CafeMenuBottomSheetProps = {

const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => {
const { cafe, onClose } = props;
const {
data: { menus, menuBoards },
} = useCafeMenus(cafe.id);
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const scrollSnapGuardHandlers = useScrollSnapGuard();

useEffect(() => {
Expand All @@ -32,10 +29,6 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => {
const handlePreventClickPropagation: React.MouseEventHandler<HTMLDivElement> = (event) => {
event.stopPropagation();
};

const recommendedMenus = menus.filter((menuItem) => menuItem.isRecommended);
const otherMenus = menus.filter((menuItem) => !menuItem.isRecommended);

return (
<>
{createPortal(
Expand All @@ -44,40 +37,67 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => {
<CloseIcon onClick={onClose} />
</CloseButton>

{menuBoards.length > 0 && (
<>
<ShowMenuBoardButton $imageUrl={menuBoards[0].imageUrl} onClick={() => setIsImageModalOpen(true)}>
메뉴판 이미지로 보기 ({menuBoards.length})
</ShowMenuBoardButton>
<Spacer $size={'8'} />
</>
)}

{recommendedMenus.length > 0 && (
<>
<CafeMenuListTitle>대표 메뉴</CafeMenuListTitle>
<CafeMenuList menus={recommendedMenus} />
<Spacer $size={'8'} />
</>
)}

{otherMenus.length > 0 && (
<>
<CafeMenuListTitle>메뉴</CafeMenuListTitle>
<CafeMenuList menus={otherMenus} />
<Spacer $size={'8'} />
</>
)}

{menus.length === 0 && (
<>
<Spacer $size={'4'} />
<Placeholder>등록된 메뉴가 없습니다</Placeholder>
</>
)}
<QueryErrorBoundary>
<Suspense>
<CafeMenuBottomSheetContent cafe={cafe} />
</Suspense>
</QueryErrorBoundary>
</Container>,
document.bodyRoot,
)}
</>
);
};

export default CafeMenuBottomSheet;

type CafeMenuBottomSheetContentProps = {
cafe: Cafe;
};

const CafeMenuBottomSheetContent = (props: CafeMenuBottomSheetContentProps) => {
const { cafe } = props;
const {
data: { menus, menuBoards },
} = useCafeMenus(cafe.id);
const [isImageModalOpen, setIsImageModalOpen] = useState(false);

const recommendedMenus = menus.filter((menuItem) => menuItem.isRecommended);
const otherMenus = menus.filter((menuItem) => !menuItem.isRecommended);

return (
<>
{menuBoards.length > 0 && (
<>
<ShowMenuBoardButton $imageUrl={menuBoards[0].imageUrl} onClick={() => setIsImageModalOpen(true)}>
메뉴판 이미지로 보기 ({menuBoards.length})
</ShowMenuBoardButton>
<Spacer $size={'8'} />
</>
)}

{recommendedMenus.length > 0 && (
<>
<CafeMenuListTitle>대표 메뉴</CafeMenuListTitle>
<CafeMenuList menus={recommendedMenus} />
<Spacer $size={'8'} />
</>
)}

{otherMenus.length > 0 && (
<>
<CafeMenuListTitle>메뉴</CafeMenuListTitle>
<CafeMenuList menus={otherMenus} />
<Spacer $size={'8'} />
</>
)}

{menus.length === 0 && (
<>
<Spacer $size={'4'} />
<Placeholder>등록된 메뉴가 없습니다</Placeholder>
</>
)}

{isImageModalOpen &&
createPortal(
Expand All @@ -91,8 +111,6 @@ const CafeMenuBottomSheet = (props: CafeMenuBottomSheetProps) => {
);
};

export default CafeMenuBottomSheet;

const Container = styled.div`
position: absolute;
bottom: 0;
Expand Down
86 changes: 86 additions & 0 deletions client/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ComponentType, ErrorInfo, PropsWithChildren, ReactNode } from 'react';
import React from 'react';

type FallbackProps<TError> = {
error: TError;
resetErrorBoundary: () => void;
};

export type ErrorBoundaryBaseProps<TError> = PropsWithChildren<{
onReset?: () => void;
caught?: (error: unknown) => error is TError;
}>;

export type ErrorBoundaryWithRenderProps<TError> = ErrorBoundaryBaseProps<TError> & {
fallbackRender: ({ error, resetErrorBoundary }: FallbackProps<TError>) => ReactNode;
FallbackComponent?: never;
};

export type ErrorBoundaryWithComponentProps<TError> = ErrorBoundaryBaseProps<TError> & {
fallbackRender?: never;
FallbackComponent: ComponentType<FallbackProps<TError>>;
};

export type ErrorBoundaryWithNothingProps<TError> = ErrorBoundaryBaseProps<TError> & {
fallbackRender?: never;
FallbackComponent?: never;
};

export type ErrorBoundaryProps<TError> =
| ErrorBoundaryWithRenderProps<TError>
| ErrorBoundaryWithComponentProps<TError>
| ErrorBoundaryWithNothingProps<TError>;

type ErrorBoundaryState<TError> = {
error: TError | null;
};

/**
* 자식 컴포넌트를 렌더링하는 중 에러가 발생하였을 때(throw error)
* 대체 컴포넌트(fallback)을 렌더합니다.
*/
class ErrorBoundary<TError = Error> extends React.Component<ErrorBoundaryProps<TError>, ErrorBoundaryState<TError>> {
constructor(props: ErrorBoundaryProps<TError>) {
super(props);
this.state = {
error: null,
};
}

static getDerivedStateFromError(error: any): ErrorBoundaryState<any> {
return { error };
}

componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
const defaultCaught = (error: unknown): error is Error => error instanceof Error;
const { caught = defaultCaught } = this.props;
if (caught(error)) {
return;
}
throw error;
}

render() {
const { error } = this.state;
if (error) {
if ('fallbackRender' in this.props && this.props.fallbackRender) {
const { fallbackRender } = this.props;
return fallbackRender({ error, resetErrorBoundary: () => this.resetErrorBoundary() });
}
if ('FallbackComponent' in this.props && this.props.FallbackComponent) {
const { FallbackComponent } = this.props;
return <FallbackComponent error={error} resetErrorBoundary={() => this.resetErrorBoundary()} />;
}
return null;
}
return this.props.children;
}

resetErrorBoundary() {
this.setState({ error: null });
const { onReset } = this.props;
onReset?.();
}
}

export default ErrorBoundary;
Loading

0 comments on commit 8564562

Please sign in to comment.