diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..34b83811 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./packages/app/dist \ No newline at end of file diff --git a/packages/app/404.html b/packages/app/404.html new file mode 100644 index 00000000..59a7be40 --- /dev/null +++ b/packages/app/404.html @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/packages/app/index.html b/packages/app/index.html index 7e663448..395f12c8 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,26 +1,44 @@ - - - - 상품 쇼핑몰 - - - + - - -
- - + }; + + + + + +
+ + diff --git a/packages/app/src/components/modal/ModalProvider.tsx b/packages/app/src/components/modal/ModalProvider.tsx index f4147ec6..d1ece93e 100644 --- a/packages/app/src/components/modal/ModalProvider.tsx +++ b/packages/app/src/components/modal/ModalProvider.tsx @@ -1,27 +1,42 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, memo, type PropsWithChildren, type ReactNode, useContext, useState } from "react"; + +import { createContext, memo, type PropsWithChildren, type ReactNode, useContext, useMemo, useState } from "react"; import { createPortal } from "react-dom"; + import { Modal } from "./Modal"; -export const ModalContext = createContext<{ - open: (content: ReactNode) => void; +type ModalContextValue = { close: () => void; -}>({ - open: () => null, + open: (content: ReactNode) => void; +}; + +const ModalContext = createContext({ close: () => null, + open: () => null, }); -export const useModalContext = () => useContext(ModalContext); +export const useModalContext = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error("ModalProvider 내에서 useModalContext을 사용해야 합니다!"); + } + + return context; +}; export const ModalProvider = memo(({ children }: PropsWithChildren) => { const [content, setContent] = useState(null); - const open = (newContent: ReactNode) => setContent(newContent); - - const close = () => setContent(null); + const modalContextValue = useMemo( + () => ({ + close: () => setContent(null), + open: setContent, + }), + [], + ); return ( - + {children} {content && createPortal({content}, document.body)} diff --git a/packages/app/src/components/toast/ToastProvider.tsx b/packages/app/src/components/toast/ToastProvider.tsx index 0aed9451..24fc7ed2 100644 --- a/packages/app/src/components/toast/ToastProvider.tsx +++ b/packages/app/src/components/toast/ToastProvider.tsx @@ -1,52 +1,87 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react"; + +import { useAutoCallback } from "@hanghae-plus/lib"; +import { createContext, memo, type PropsWithChildren, useContext, useMemo, useReducer } from "react"; import { createPortal } from "react-dom"; + +import { debounce } from "../../utils"; import { Toast } from "./Toast"; import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; -import { debounce } from "../../utils"; -type ShowToast = (message: string, type: ToastType) => void; -type Hide = () => void; +type ToastCommandContextValue = { + hide: () => void; + show: (message: string, type: ToastType) => void; +}; -const ToastContext = createContext<{ +type ToastStateContextValue = { message: string; type: ToastType; - show: ShowToast; - hide: Hide; -}>({ - ...initialState, +}; + +const ToastCommandContext = createContext({ show: () => null, hide: () => null, }); +const ToastStateContext = createContext({ + ...initialState, +}); + const DEFAULT_DELAY = 3000; -const useToastContext = () => useContext(ToastContext); export const useToastCommand = () => { - const { show, hide } = useToastContext(); - return { show, hide }; + const context = useContext(ToastCommandContext); + if (!context) { + throw new Error("ToastProvider 내에서 useToastCommand을 사용해야 합니다!"); + } + + return context; }; + export const useToastState = () => { - const { message, type } = useToastContext(); - return { message, type }; + const context = useContext(ToastStateContext); + if (!context) { + throw new Error("ToastProvider 내에서 useToastState을 사용해야 합니다!"); + } + + return context; }; export const ToastProvider = memo(({ children }: PropsWithChildren) => { const [state, dispatch] = useReducer(toastReducer, initialState); - const { show, hide } = createActions(dispatch); - const visible = state.message !== ""; - const hideAfter = debounce(hide, DEFAULT_DELAY); + const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]); + const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); + + const visible = state.message !== ""; - const showWithHide: ShowToast = (...args) => { - show(...args); + const showWithHide = useAutoCallback((message: string, type: ToastType) => { + show(message, type); hideAfter(); - }; + }); + + const toastCommandContextValue: ToastCommandContextValue = useMemo( + () => ({ + hide, + show: showWithHide, + }), + [hide, showWithHide], + ); + + const toastStateContextValue: ToastStateContextValue = useMemo( + () => ({ + message: state.message, + type: state.type, + }), + [state.message, state.type], + ); return ( - - {children} - {visible && createPortal(, document.body)} - + + + {children} + {visible && createPortal(, document.body)} + + ); }); diff --git a/packages/lib/src/createObserver.ts b/packages/lib/src/createObserver.ts index fe97bf6b..50759bf0 100644 --- a/packages/lib/src/createObserver.ts +++ b/packages/lib/src/createObserver.ts @@ -3,16 +3,24 @@ type Listener = () => void; export const createObserver = () => { const listeners = new Set(); - // useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다. const subscribe = (fn: Listener) => { listeners.add(fn); + + return () => { + unsubscribe(fn); + }; }; const unsubscribe = (fn: Listener) => { listeners.delete(fn); }; - const notify = () => listeners.forEach((listener) => listener()); + const notify = () => { + listeners.forEach((listener) => listener()); + }; - return { subscribe, notify }; + return { + subscribe, + notify, + }; }; diff --git a/packages/lib/src/equals/deepEquals.ts b/packages/lib/src/equals/deepEquals.ts index f5f4ebd5..d530dcb5 100644 --- a/packages/lib/src/equals/deepEquals.ts +++ b/packages/lib/src/equals/deepEquals.ts @@ -1,3 +1,24 @@ +import { compareArrays, compareObjects, dispatchWithCondition, isArray, isObject } from "../utils"; + +/** + * 두 값의 깊은 비교를 수행 + * + * - 기본 타입 값들을 정확히 비교해야 한다 + * - 배열을 정확히 비교해야 한다 + * - 객체를 정확히 비교해야 한다 + * - 중첩된 구조를 정확히 비교해야 한다 + */ export const deepEquals = (a: unknown, b: unknown) => { - return a === b; + return dispatchWithCondition<[typeof a, typeof b], boolean>( + // 두 값이 정확히 같은지 확인 (참조가 같은 경우) + [([a, b]) => Object.is(a, b), () => true], + // 둘 다 객체가 아니면 false + [([a, b]) => !isObject(a) || !isObject(b), () => false], + // 서로 다른 타입을 받을 경우 false (ex: a: [], b: {} 인 경우 false) + [([a, b]) => isArray(a) !== isArray(b), () => false], + // 둘 다 배열이면 배열 비교 + [([a, b]) => isArray(a) && isArray(b), ([a, b]) => compareArrays(a as unknown[], b as unknown[], deepEquals)], + // 둘 다 객체면 객체 비교 + ([a, b]) => compareObjects(a as object, b as object, deepEquals), + )([a, b]); }; diff --git a/packages/lib/src/equals/shallowEquals.ts b/packages/lib/src/equals/shallowEquals.ts index 1cf22130..500ceb19 100644 --- a/packages/lib/src/equals/shallowEquals.ts +++ b/packages/lib/src/equals/shallowEquals.ts @@ -1,3 +1,23 @@ +import { compareArrays, compareObjects, dispatchWithCondition, isArray, isObject } from "../utils"; + +/** + * 두 값의 얕은 비교를 수행 + * + * - 기본 타입 값들을 정확히 비교해야 한다 + * - 배열을 얕게 비교해야 한다 + * - 중첩된 구조를 깊게 비교하지 않아야 한다 + */ export const shallowEquals = (a: unknown, b: unknown) => { - return a === b; + return dispatchWithCondition<[typeof a, typeof b], boolean>( + // 두 값이 정확히 같은지 확인 (참조가 같은 경우) + [([a, b]) => Object.is(a, b), () => true], + // 둘 다 객체가 아니면 false + [([a, b]) => !isObject(a) || !isObject(b), () => false], + // 서로 다른 타입을 받을 경우 false (ex: a: [], b: {} 인 경우 false) + [([a, b]) => isArray(a) !== isArray(b), () => false], + // 둘 다 배열이면 배열 비교 + [([a, b]) => isArray(a) && isArray(b), ([a, b]) => compareArrays(a as unknown[], b as unknown[])], + // 둘 다 객체면 객체 비교 + ([a, b]) => compareObjects(a as object, b as object), + )([a, b]); }; diff --git a/packages/lib/src/hocs/deepMemo.ts b/packages/lib/src/hocs/deepMemo.ts index 13f7f18e..3ecc01d0 100644 --- a/packages/lib/src/hocs/deepMemo.ts +++ b/packages/lib/src/hocs/deepMemo.ts @@ -1,5 +1,8 @@ import type { FunctionComponent } from "react"; +import { deepEquals } from "../equals"; +import { memo } from "./memo"; + export function deepMemo

(Component: FunctionComponent

) { - return Component; + return memo(Component, deepEquals); } diff --git a/packages/lib/src/hocs/memo.ts b/packages/lib/src/hocs/memo.ts index 532c3a5c..01b5be30 100644 --- a/packages/lib/src/hocs/memo.ts +++ b/packages/lib/src/hocs/memo.ts @@ -1,6 +1,25 @@ import { type FunctionComponent } from "react"; + import { shallowEquals } from "../equals"; +import { useRef } from "../hooks"; + +type CacheContainer

= { + prevProps: P | null; + lastResult: ReturnType> | null; +}; export function memo

(Component: FunctionComponent

, equals = shallowEquals) { - return Component; + const MemoizedComponent: FunctionComponent

= (props) => { + const cache = useRef>({ lastResult: null, prevProps: null }); + + const shouldRender = !equals(cache.current.prevProps, props); + if (shouldRender) { + cache.current.lastResult = Component(props); + cache.current.prevProps = props; + } + + return cache.current.lastResult; + }; + + return MemoizedComponent; } diff --git a/packages/lib/src/hooks/useAutoCallback.ts b/packages/lib/src/hooks/useAutoCallback.ts index 23100162..41fc8787 100644 --- a/packages/lib/src/hooks/useAutoCallback.ts +++ b/packages/lib/src/hooks/useAutoCallback.ts @@ -2,6 +2,15 @@ import type { AnyFunction } from "../types"; import { useCallback } from "./useCallback"; import { useRef } from "./useRef"; -export const useAutoCallback = (fn: T): T => { - return fn; +export const useAutoCallback = (fn: T) => { + const fnRef = useRef(fn); + + // 매 렌더링마다 최신 함수로 업데이트 + fnRef.current = fn; + + const autoCallback = useCallback((...args: Parameters) => { + return fnRef.current(...args); + }, []); + + return autoCallback; }; diff --git a/packages/lib/src/hooks/useCallback.ts b/packages/lib/src/hooks/useCallback.ts index 712bc898..c78e048e 100644 --- a/packages/lib/src/hooks/useCallback.ts +++ b/packages/lib/src/hooks/useCallback.ts @@ -1,7 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ import type { DependencyList } from "react"; -export function useCallback(factory: T, _deps: DependencyList) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; +import type { AnyFunction } from "../types"; +import { useMemo as _useMemo } from "./useMemo"; // 린트 경고 방지를 위한 alias + +export function useCallback(factory: T, _deps: DependencyList) { + return _useMemo(() => factory, _deps); } diff --git a/packages/lib/src/hooks/useDeepMemo.ts b/packages/lib/src/hooks/useDeepMemo.ts index 3157958c..af563987 100644 --- a/packages/lib/src/hooks/useDeepMemo.ts +++ b/packages/lib/src/hooks/useDeepMemo.ts @@ -1,9 +1,8 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import type { DependencyList } from "react"; -import { useMemo } from "./useMemo"; + import { deepEquals } from "../equals"; +import { useMemo as _useMemo } from "./useMemo"; // 린트 경고 방지를 위한 alias -export function useDeepMemo(factory: () => T, deps: DependencyList): T { - // 직접 작성한 useMemo를 참고해서 만들어보세요. - return useMemo(factory, deps, deepEquals); +export function useDeepMemo(factory: () => T, deps: DependencyList) { + return _useMemo(factory, deps, deepEquals); } diff --git a/packages/lib/src/hooks/useMemo.ts b/packages/lib/src/hooks/useMemo.ts index e80692e2..c7dec633 100644 --- a/packages/lib/src/hooks/useMemo.ts +++ b/packages/lib/src/hooks/useMemo.ts @@ -1,8 +1,22 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import type { DependencyList } from "react"; +import { type DependencyList } from "react"; + import { shallowEquals } from "../equals"; +import { useRef } from "./useRef"; + +type MemoRef = { + deps: DependencyList; + value: T; +}; + +export function useMemo(factory: () => T, _deps: DependencyList, _equals = shallowEquals) { + // useRef로 메모이제이션 상태 저장 + const memoRef = useRef | null>(null); + + // 의존성 배열이 없거나, 이전과 다르면 새로 계산 + if (!memoRef.current || !_equals(memoRef.current.deps, _deps)) { + const value = factory(); + memoRef.current = { deps: _deps, value }; + } -export function useMemo(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); + return memoRef.current.value; } diff --git a/packages/lib/src/hooks/useRef.ts b/packages/lib/src/hooks/useRef.ts index 285d4ae7..7c91f1b8 100644 --- a/packages/lib/src/hooks/useRef.ts +++ b/packages/lib/src/hooks/useRef.ts @@ -1,4 +1,11 @@ -export function useRef(initialValue: T): { current: T } { - // useState를 이용해서 만들어보세요. - return { current: initialValue }; +import { useState } from "react"; + +type RefObject = { + current: T; +}; + +export function useRef(initialValue: T) { + // useState를 이용해서 리렌더링 간에 동일한 참조를 유지하는 ref 객체를 생성 + const [refObject] = useState>(() => ({ current: initialValue })); + return refObject; } diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index a18ae01f..846a1cb5 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -1,12 +1,14 @@ +import { useSyncExternalStore } from "react"; + import type { RouterInstance } from "../Router"; import type { AnyFunction } from "../types"; -import { useSyncExternalStore } from "react"; import { useShallowSelector } from "./useShallowSelector"; const defaultSelector = (state: T) => state as unknown as S; export const useRouter = , S>(router: T, selector = defaultSelector) => { - // useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다. const shallowSelector = useShallowSelector(selector); - return shallowSelector(router); + const getSnapshot = () => shallowSelector(router); + + return useSyncExternalStore(router.subscribe, getSnapshot); }; diff --git a/packages/lib/src/hooks/useShallowSelector.ts b/packages/lib/src/hooks/useShallowSelector.ts index b75ed791..8c36c57f 100644 --- a/packages/lib/src/hooks/useShallowSelector.ts +++ b/packages/lib/src/hooks/useShallowSelector.ts @@ -1,9 +1,22 @@ -import { useRef } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "./useRef"; type Selector = (state: T) => S; export const useShallowSelector = (selector: Selector) => { - // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. - return (state: T): S => selector(state); + const prevResult = useRef(null); + + const memoizedSelector = (state: T) => { + const result = selector(state); + + // 이전 결과가 있고, shallow 비교에서 동일하면 이전 결과 반환 + if (prevResult.current && shallowEquals(prevResult.current, result)) { + return prevResult.current; + } + + prevResult.current = result; + return result; + }; + + return memoizedSelector; }; diff --git a/packages/lib/src/hooks/useShallowState.ts b/packages/lib/src/hooks/useShallowState.ts index 7b589748..7d197b7e 100644 --- a/packages/lib/src/hooks/useShallowState.ts +++ b/packages/lib/src/hooks/useShallowState.ts @@ -1,7 +1,17 @@ -import { useState } from "react"; +import { useState, type Dispatch, type SetStateAction } from "react"; + import { shallowEquals } from "../equals"; +import { useCallback } from "./useCallback"; + +export const useShallowState = (initialValue: T): [T, Dispatch>] => { + const [value, setValue] = useState(initialValue); + + const setShallow = useCallback((newValue: SetStateAction) => { + setValue((prev) => { + const nextValue = typeof newValue === "function" ? (newValue as (prevValue: T) => T)(prev) : newValue; + return shallowEquals(prev, nextValue) ? prev : nextValue; + }); + }, []); -export const useShallowState = (initialValue: Parameters>[0]) => { - // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. - return useState(initialValue); + return [value, setShallow]; }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index fdc97a6f..ab94e2fb 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -4,6 +4,6 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; export const useStorage = (storage: Storage) => { - // useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요. - return storage.get(); + const storageStore = useSyncExternalStore(storage.subscribe, storage.get); + return storageStore; }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index acf3ad79..33fcd409 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -1,13 +1,15 @@ -import type { createStore } from "../createStore"; import { useSyncExternalStore } from "react"; + +import type { createStore } from "../createStore"; import { useShallowSelector } from "./useShallowSelector"; type Store = ReturnType>; -const defaultSelector = (state: T) => state as unknown as S; +const defaultSelector = (state: T) => state as unknown as S; -export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { - // useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요. +export const useStore = (store: Store, selector = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return shallowSelector(store.getState()); + const getSnapshot = () => shallowSelector(store.getState()); + + return useSyncExternalStore(store.subscribe, getSnapshot); }; diff --git a/packages/lib/src/utils/condition.ts b/packages/lib/src/utils/condition.ts new file mode 100644 index 00000000..5802debe --- /dev/null +++ b/packages/lib/src/utils/condition.ts @@ -0,0 +1,18 @@ +type Condition = (param: T) => boolean; +type Handler = (param: T) => R; +type ConditionHandlerPair = [condition: Condition, handler: Handler]; + +export function dispatchWithCondition(...args: [...ConditionHandlerPair[], Handler]) { + const pairs = args.slice(0, -1) as ConditionHandlerPair[]; + const defaultHandler = args[args.length - 1] as Handler; + + return (param: T) => { + for (const [condition, handler] of pairs) { + if (condition(param)) { + return handler(param); + } + } + + return defaultHandler(param); + }; +} diff --git a/packages/lib/src/utils/helper.ts b/packages/lib/src/utils/helper.ts new file mode 100644 index 00000000..455c4ed5 --- /dev/null +++ b/packages/lib/src/utils/helper.ts @@ -0,0 +1,21 @@ +export function isArray(value: unknown) { + return Array.isArray(value); +} + +export function isObject(value: unknown) { + return typeof value === "object" && value !== null; +} + +export function compareArrays(a: unknown[], b: unknown[], compareFn = Object.is) { + return a.length === b.length && a.every((item, index) => compareFn(item, b[index])); +} + +export function compareObjects(a: object, b: object, compareFn = Object.is) { + const aObj = a as Record; + const bObj = b as Record; + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + return keysA.length === keysB.length && keysA.every((key) => key in bObj && compareFn(aObj[key], bObj[key])); +} diff --git a/packages/lib/src/utils/index.ts b/packages/lib/src/utils/index.ts new file mode 100644 index 00000000..c14d2877 --- /dev/null +++ b/packages/lib/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./condition"; +export * from "./helper";