From 159d4e29d4330ed5aa627cfbba0ae826a0a964f6 Mon Sep 17 00:00:00 2001 From: Collection50 Date: Fri, 19 Jul 2024 21:06:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Suspense,=20Asyncboundary,=20ErrorBound?= =?UTF-8?q?ary,=20generateContext=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 5 +- src/context/AsyncBoundary/AsyncBoundary.tsx | 62 +++++++ src/context/ErrorBoundary/ErrorBoundary.tsx | 154 ++++++++++++++++++ .../ErrorBoundary/ErrorBoundaryContext.tsx | 12 ++ src/context/Providers.tsx | 8 - src/context/QueryProvider/QueryProvider.tsx | 8 + src/context/QueryProvider/ReactQuery.tsx | 31 ++++ .../SSRSafeSuspense/SSRSafeSuspense.tsx | 13 ++ .../generateContext/generateContext.tsx | 78 +++++++++ src/context/queryProvider/ReactQuery.tsx | 4 +- src/hooks/index.ts | 1 + src/hooks/useIsClient/index.ts | 11 ++ .../Button/stories/Button.stories.tsx | 6 +- src/system/components/Icon/Icon.tsx | 3 +- src/system/components/Icon/SVG/Bell.tsx | 9 +- src/system/components/Icon/SVG/Folder.tsx | 16 +- src/system/components/Icon/SVG/Logout.tsx | 9 +- src/system/components/Icon/SVG/Memo.tsx | 9 +- src/system/components/Icon/SVG/Profile.tsx | 9 +- src/system/components/Icon/SVG/Search.tsx | 25 ++- src/system/components/Icon/SVG/Setting.tsx | 9 +- .../components/Icon/stories/Icon.stories.tsx | 6 +- src/system/components/Text/Text.tsx | 5 +- .../components/Text/stories/Text.stories.tsx | 12 +- .../components/Text/styles/useTextStyles.ts | 4 +- src/system/components/Text/styles/variants.ts | 3 +- src/types/index.ts | 1 + src/types/react.ts | 3 + src/util/array.ts | 6 + src/util/function.ts | 11 ++ src/util/index.ts | 2 + 31 files changed, 489 insertions(+), 46 deletions(-) create mode 100644 src/context/AsyncBoundary/AsyncBoundary.tsx create mode 100644 src/context/ErrorBoundary/ErrorBoundary.tsx create mode 100644 src/context/ErrorBoundary/ErrorBoundaryContext.tsx delete mode 100644 src/context/Providers.tsx create mode 100644 src/context/QueryProvider/QueryProvider.tsx create mode 100644 src/context/QueryProvider/ReactQuery.tsx create mode 100644 src/context/SSRSafeSuspense/SSRSafeSuspense.tsx create mode 100644 src/context/generateContext/generateContext.tsx create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useIsClient/index.ts create mode 100644 src/types/index.ts create mode 100644 src/types/react.ts create mode 100644 src/util/array.ts create mode 100644 src/util/function.ts create mode 100644 src/util/index.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 28da9d37..1d71ce3d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; +import QueryProvider from '@/context/QueryProvider/QueryProvider'; const inter = Inter({ subsets: ['latin'] }); @@ -16,7 +17,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/src/context/AsyncBoundary/AsyncBoundary.tsx b/src/context/AsyncBoundary/AsyncBoundary.tsx new file mode 100644 index 00000000..9fc5eb77 --- /dev/null +++ b/src/context/AsyncBoundary/AsyncBoundary.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { forwardRef } from 'react'; +import { useQueryErrorResetBoundary } from '@tanstack/react-query'; +import { chain } from '@/util'; +import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'; +import SSRSafeSuspense from '../SSRSafeSuspense/SSRSafeSuspense'; +import type { StrictPropsWithChildren } from '@/types'; +import type { ComponentProps, ComponentRef, Suspense } from 'react'; + +type ErrorBoundaryProps = Omit< + ComponentProps, + 'renderFallback' +>; +type SuspenseProps = Omit, 'fallback'>; + +type AsyncBoundrayProps = StrictPropsWithChildren & + ErrorBoundaryProps & + SuspenseProps & { + errorFallback?: ComponentProps['renderFallback']; + pendingFallback?: ComponentProps['fallback']; + }; + +export const AsyncBoundary = forwardRef< + ComponentRef, + AsyncBoundrayProps +>( + ( + { errorFallback, pendingFallback, children, ...errorBoundaryProps }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +AsyncBoundary.displayName = 'AsyncBoundary'; + +export const AsyncBoundaryWithQuery = forwardRef< + ComponentRef, + AsyncBoundrayProps +>((props, ref) => { + const { children, ...otherProps } = props; + const { reset } = useQueryErrorResetBoundary(); + + return ( + + {children} + + ); +}); + +AsyncBoundaryWithQuery.displayName = 'AsyncBoundaryWithQuery'; diff --git a/src/context/ErrorBoundary/ErrorBoundary.tsx b/src/context/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..e83771d5 --- /dev/null +++ b/src/context/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { + Component, + forwardRef, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { isDifferentArray } from '@/util'; +import { ErrorboundaryProvider } from './ErrorBoundaryContext'; +import type { StrictPropsWithChildren } from '@/types'; +import type { + ComponentPropsWithoutRef, + ErrorInfo, + PropsWithRef, + ReactNode, +} from 'react'; + +type RenderFallbackProps = { + error: ErrorType; + reset?: () => void; +}; + +type RenderFallbackType = ( + props: RenderFallbackProps, +) => ReactNode; + +type FallbackType = ReactNode; + +type ErrorBoundaryProps = { + onReset?(): void; + renderFallback: RenderFallbackType | FallbackType; + onError?(error: ErrorType, info: ErrorInfo): void; + resetKeys?: unknown[]; +}; + +interface State { + error: ErrorType | null; +} + +const initialState: State = { + error: null, +}; + +export class ErrorBoundary extends Component< + PropsWithRef>, + State +> { + hasError = false; + + constructor( + props: PropsWithRef>, + ) { + super(props); + this.state = initialState; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidUpdate(prevProps: ErrorBoundaryProps) { + const { error } = this.state; + const { resetKeys } = this.props; + + if (error === null) { + return; + } + if (!this.hasError) { + this.hasError = true; + return; + } + + if (isDifferentArray(prevProps.resetKeys, resetKeys)) { + this.resetErrorBoundary(); + } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + const { onError } = this.props; + + onError?.(error, info); + } + + resetErrorBoundary = () => { + const { onReset } = this.props; + + onReset?.(); + this.resetState(); + }; + + resetState() { + this.hasError = false; + this.setState(initialState); + } + + genereateRenderedChildren() { + const { error } = this.state; + const { children, renderFallback } = this.props; + + if (error === null) { + return children; + } + + return typeof renderFallback === 'function' + ? renderFallback({ + error: error as Error, + reset: this.resetErrorBoundary, + }) + : renderFallback; + } + + render() { + const { error } = this.state; + + const renderedChildren = this.genereateRenderedChildren(); + const ErrorboundaryProviderProps = { + error, + resetErrorBoundary: this.resetErrorBoundary, + }; + + return ( + + {renderedChildren} + + ); + } +} + +export const GlobalErrorBoundary = forwardRef< + { reset(): void }, + ComponentPropsWithoutRef +>((props, resetRef) => { + const ref = useRef(null); + + useImperativeHandle(resetRef, () => ({ + reset: () => ref.current?.resetErrorBoundary(), + })); + + return ; +}); + +GlobalErrorBoundary.displayName = 'GlobalErrorBoundary'; + +export const useErrorBoundary = () => { + const [error, setError] = useState(null); + + if (error != null) { + throw error as Error; + } + + return setError; +}; diff --git a/src/context/ErrorBoundary/ErrorBoundaryContext.tsx b/src/context/ErrorBoundary/ErrorBoundaryContext.tsx new file mode 100644 index 00000000..75207e47 --- /dev/null +++ b/src/context/ErrorBoundary/ErrorBoundaryContext.tsx @@ -0,0 +1,12 @@ +'use client'; + +import generateContext from '../generateContext/generateContext'; + +export const [ErrorboundaryProvider, useErrorBoundaryContext] = + generateContext<{ + error: Error | null; + resetErrorBoundary: () => void; + }>({ + name: 'global-error-boundary-context', + defaultContextValue: { error: null, resetErrorBoundary: () => {} }, + }); diff --git a/src/context/Providers.tsx b/src/context/Providers.tsx deleted file mode 100644 index 513f9151..00000000 --- a/src/context/Providers.tsx +++ /dev/null @@ -1,8 +0,0 @@ -'use client'; - -import type { PropsWithChildren } from 'react'; -import ReactQuery from './queryProvider/ReactQuery'; - -export default function Providers({ children }: PropsWithChildren) { - return {children}; -} diff --git a/src/context/QueryProvider/QueryProvider.tsx b/src/context/QueryProvider/QueryProvider.tsx new file mode 100644 index 00000000..285df395 --- /dev/null +++ b/src/context/QueryProvider/QueryProvider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import ReactQuery from './ReactQuery'; +import type { PropsWithChildren } from 'react'; + +export default function QueryProvider({ children }: PropsWithChildren) { + return {children}; +} diff --git a/src/context/QueryProvider/ReactQuery.tsx b/src/context/QueryProvider/ReactQuery.tsx new file mode 100644 index 00000000..b1ddf0e8 --- /dev/null +++ b/src/context/QueryProvider/ReactQuery.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import type { QueryClientConfig } from '@tanstack/react-query'; + +const queryClientOption: QueryClientConfig = { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + // NOTE: 추후 배포 시 변경 + staleTime: Infinity, + }, + mutations: { + networkMode: 'always', + }, + }, +}; + +export default function ReactQuery({ children }: PropsWithChildren) { + const [queryClient] = useState(() => new QueryClient(queryClientOption)); + + return ( + + {children} + + + ); +} diff --git a/src/context/SSRSafeSuspense/SSRSafeSuspense.tsx b/src/context/SSRSafeSuspense/SSRSafeSuspense.tsx new file mode 100644 index 00000000..85a50070 --- /dev/null +++ b/src/context/SSRSafeSuspense/SSRSafeSuspense.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useIsClient } from '@/hooks'; +import { Suspense } from 'react'; +import type { ComponentProps } from 'react'; + +export default function SSRSafeSuspense(props: ComponentProps) { + if (!useIsClient()) { + // eslint-disable-next-line react/destructuring-assignment + return props.fallback; + } + return ; +} diff --git a/src/context/generateContext/generateContext.tsx b/src/context/generateContext/generateContext.tsx new file mode 100644 index 00000000..8aa03225 --- /dev/null +++ b/src/context/generateContext/generateContext.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { createContext, useContext as ReactUseContext, useMemo } from 'react'; +import type { ReactNode, Context as ReactContext } from 'react'; + +interface ContextOptions { + name: string; + errorMessage?: string; + defaultContextValue?: ContextType; + strict?: boolean; +} + +type GenerateContextReturnType = [ + ({ children, ...providerValues }: ProviderProps) => JSX.Element, + () => T, + ReactContext, +]; + +type ProviderProps = + | (ContextValuesType & { children: ReactNode }) + | { children: ReactNode }; + +export default function generateContext( + options: ContextOptions, +) { + const { + name, + errorMessage = `${name} Context가 존재하지 않습니다. Provider를 설정해주세요.`, + defaultContextValue = null, + strict = true, + } = options; + const Context = createContext( + defaultContextValue ?? null, + ); + + function useContext() { + const context = ReactUseContext(Context); + + if (context !== null) { + return context; + } + + if (defaultContextValue !== null) { + return defaultContextValue; + } + + if (strict) { + const error = new Error(errorMessage); + error.name = `${name} ContextError`; + Error.captureStackTrace?.(error, useContext); + throw error; + } + + return context; + } + + function Provider({ + children, + ...providerValues + }: ProviderProps) { + const value = useMemo( + () => (Object.keys(providerValues).length > 0 ? providerValues : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [...Object.entries(providerValues).flat()], + ) as ContextType; + + return {children}; + } + + Context.displayName = name; + Provider.displayName = name; + + return [ + Provider, + useContext, + Context, + ] as GenerateContextReturnType; +} diff --git a/src/context/queryProvider/ReactQuery.tsx b/src/context/queryProvider/ReactQuery.tsx index 329b2508..b1ddf0e8 100644 --- a/src/context/queryProvider/ReactQuery.tsx +++ b/src/context/queryProvider/ReactQuery.tsx @@ -1,10 +1,10 @@ 'use client'; -import type { QueryClientConfig } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import type { PropsWithChildren } from 'react'; import { useState } from 'react'; +import type { PropsWithChildren } from 'react'; +import type { QueryClientConfig } from '@tanstack/react-query'; const queryClientOption: QueryClientConfig = { defaultOptions: { diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000..46ca4003 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export { default as useIsClient } from './useIsClient'; diff --git a/src/hooks/useIsClient/index.ts b/src/hooks/useIsClient/index.ts new file mode 100644 index 00000000..7138d384 --- /dev/null +++ b/src/hooks/useIsClient/index.ts @@ -0,0 +1,11 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +export default function useIsClient() { + return useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); +} diff --git a/src/system/components/Button/stories/Button.stories.tsx b/src/system/components/Button/stories/Button.stories.tsx index 53396106..b52be660 100644 --- a/src/system/components/Button/stories/Button.stories.tsx +++ b/src/system/components/Button/stories/Button.stories.tsx @@ -1,5 +1,5 @@ -import { Meta } from '@storybook/react'; import { Button } from '../Button'; +import type { Meta } from '@storybook/react'; const meta = { title: 'Design System/Button', @@ -9,6 +9,6 @@ const meta = { export default meta; -export const Example = () => { +export function Example() { return ; -}; +} diff --git a/src/system/components/Icon/Icon.tsx b/src/system/components/Icon/Icon.tsx index 2edabb2d..6e0f1331 100644 --- a/src/system/components/Icon/Icon.tsx +++ b/src/system/components/Icon/Icon.tsx @@ -1,12 +1,11 @@ -import { forwardRef } from 'react'; import { Search } from './SVG/Search'; -import { IconBaseType } from '@/system/components/Icon/SVG/type'; import { Folder } from './SVG/Folder'; import { Bell } from './SVG/Bell'; import { Memo } from './SVG/Memo'; import { Profile } from './SVG/Profile'; import { Setting } from './SVG/Setting'; import { Logout } from './SVG/Logout'; +import type { IconBaseType } from '@/system/components/Icon/SVG/type'; const iconMap = { bell: Bell, diff --git a/src/system/components/Icon/SVG/Bell.tsx b/src/system/components/Icon/SVG/Bell.tsx index 886eb5c7..8b218586 100644 --- a/src/system/components/Icon/SVG/Bell.tsx +++ b/src/system/components/Icon/SVG/Bell.tsx @@ -1,8 +1,13 @@ -import { IconBaseType } from './type'; +import type { IconBaseType } from './type'; export function Bell({ size, color }: IconBaseType) { return ( - + - + + + + + - - + + + ); } diff --git a/src/system/components/Icon/SVG/Setting.tsx b/src/system/components/Icon/SVG/Setting.tsx index e8690e4b..0a2a9449 100644 --- a/src/system/components/Icon/SVG/Setting.tsx +++ b/src/system/components/Icon/SVG/Setting.tsx @@ -1,8 +1,13 @@ -import { IconBaseType } from './type'; +import type { IconBaseType } from './type'; export function Setting({ size, color }: IconBaseType) { return ( - + { +export function Example() { return ( <> @@ -21,4 +21,4 @@ export const Example = () => { ); -}; +} diff --git a/src/system/components/Text/Text.tsx b/src/system/components/Text/Text.tsx index 67388f13..7ebc2b99 100644 --- a/src/system/components/Text/Text.tsx +++ b/src/system/components/Text/Text.tsx @@ -1,6 +1,7 @@ -import { Typography } from '@/system/token/typography'; -import { ComponentProps, ElementType, forwardRef } from 'react'; +import { forwardRef } from 'react'; import { useTextStyles } from './styles/useTextStyles'; +import type { ComponentProps, ElementType } from 'react'; +import type { Typography } from '@/system/token/typography'; // TODO: Polymorphic하게 변경 export type TextProps = ComponentProps<'p'> & { diff --git a/src/system/components/Text/stories/Text.stories.tsx b/src/system/components/Text/stories/Text.stories.tsx index 35d6a5a8..25aaa231 100644 --- a/src/system/components/Text/stories/Text.stories.tsx +++ b/src/system/components/Text/stories/Text.stories.tsx @@ -1,6 +1,6 @@ -import { Meta } from '@storybook/react'; -import { Text } from '../Text'; import { typographyVariant } from '@/system/token/typography'; +import { Text } from '../Text'; +import type { Meta } from '@storybook/react'; const meta = { title: 'Design system/Text', @@ -10,12 +10,14 @@ const meta = { export default meta; -export const Example = () => { +export function Example() { return ( <> {typographyVariant.map((typography) => ( - 테스트입니다 + + 테스트입니다 + ))} ); -}; +} diff --git a/src/system/components/Text/styles/useTextStyles.ts b/src/system/components/Text/styles/useTextStyles.ts index 5c6bc61f..fb43897e 100644 --- a/src/system/components/Text/styles/useTextStyles.ts +++ b/src/system/components/Text/styles/useTextStyles.ts @@ -1,7 +1,7 @@ import { clsx } from 'clsx'; -import { Typography } from '@/system/token/typography'; import { textVariants } from './variants'; -import { TextAnatomy } from '../anatomy'; +import type { Typography } from '@/system/token/typography'; +import type { TextAnatomy } from '../anatomy'; interface Props { className?: string; diff --git a/src/system/components/Text/styles/variants.ts b/src/system/components/Text/styles/variants.ts index 535e7824..d8c73053 100644 --- a/src/system/components/Text/styles/variants.ts +++ b/src/system/components/Text/styles/variants.ts @@ -1,5 +1,6 @@ -import { Typography, typographyVariant } from '@/system/token/typography'; +import { typographyVariant } from '@/system/token/typography'; import { tv } from 'tailwind-variants'; +import type { Typography } from '@/system/token/typography'; export const textVariants = tv({ base: 'm-0', diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..5d395807 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './react'; diff --git a/src/types/react.ts b/src/types/react.ts new file mode 100644 index 00000000..940d3a40 --- /dev/null +++ b/src/types/react.ts @@ -0,0 +1,3 @@ +import type { ReactNode } from 'react'; + +export type StrictPropsWithChildren

= P & { children: ReactNode }; diff --git a/src/util/array.ts b/src/util/array.ts new file mode 100644 index 00000000..dc45ae08 --- /dev/null +++ b/src/util/array.ts @@ -0,0 +1,6 @@ +export const isDifferentArray = (a: unknown[] = [], b: unknown[] = []) => { + if (a.length !== b.length) { + return false; + } + return a.some((item, index) => !Object.is(item, b[index])); +}; diff --git a/src/util/function.ts b/src/util/function.ts new file mode 100644 index 00000000..c12a95e7 --- /dev/null +++ b/src/util/function.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function chain(...callbacks: any[]): (...args: any[]) => void { + return (...args: any[]) => { + for (const callback of callbacks) { + if (typeof callback === 'function') { + callback(...args); + } + } + }; +} diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 00000000..4e7d9de0 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,2 @@ +export * from './array'; +export * from './function';