Skip to content

Commit

Permalink
refactor: Optimize performance with comprehensive component optimizat…
Browse files Browse the repository at this point in the history
…ion and modular architecture (#194)

* chore: Remove @tanstack/react-query

* refactor: Extract checkProduction logic from api.config.ts

* refactor: Optimize re-rendering with individual selectors and memo

- Wrap PlayerCardList component with memo to prevent unnecessary re-renders
- Replace object destructuring from useGameSocketStore with individual selectors
- Split store access into separate selectors for better performance:
 - players
 - hostId
 - roundAssignedRole
 - currentPlayerId
- Maintain same functionality while reducing re-renders from unrelated state changes

* refactor(Chat): split Chat component into modular components and optimize performance

- Decompose monolithic Chat component into smaller focused components:
 - ChatContainer: Top-level socket management
 - ChatList: Message display and scroll management
 - ChatInput: Message input and submission
 - ChatBubble: Individual message UI
- Add memo to all components to prevent unnecessary re-renders
- Implement individual selectors for Zustand store access
- Enhance useDrawingSocket hook with submitDrawing event handler
- Improve code organization and separation of concerns

* fix: Modify mobile-web-app-capable

* refactor: Optimize performance of Setting component

- Optimize state subscription
  - Prevent unnecessary re-renders by separating useGameSocketStore selectors
  - Change to individually subscribe only to roomSettings and isHost states
- Component memoization
  - Apply memo to Setting component
  - Memoize handleSettingChange callback function with useCallback
- Improve component structure
  - Separate concerns by extracting SettingContent component
  - Define RoomSettingItem type and separate setting constants
  - Reduce unnecessary JSX nesting
- Enhance type safety
  - Add SettingKey type alias
  - Export RoomSettingItem interface for reusability

* Merge branch 'develop' into refactor/#164

* feat: Enhance SHORTCUT_KEYS with additional information and Korean support

- Rename SHORTCUT_KEY to SHORTCUT_KEYS for consistency
- Add description for each shortcut
- Include alternative keys for most shortcuts
- Add Korean character alternatives for game-related shortcuts
- Group shortcuts by category (settings and game-related)
- Change number keys for dropdown settings (1, 2, 3 instead of q, w, e)
- Use 'as const' for type safety

* feat: Implement useShortcuts custom hook for advanced shortcut handling

- Create useShortcuts custom hook for managing keyboard shortcuts
- Add support for main and alternative keys defined in SHORTCUT_KEYS
- Implement shortcut disabling for input elements (input, textarea, select)
- Allow dynamic configuration of shortcuts with action and disabled state
- Prevent default browser behavior when shortcut is triggered
- Ensure proper cleanup of event listener on component unmount

* feat: Apply useShortcuts hook across components for shortcut functionality

- Add useShortcuts hook
  • ChatInput
  • InviteButton
  • StartButton
  • Setting
  • SettingItem
  • Dropdown
- Integrate useShortcuts in useDropdown and useStartButton hooks
- Update ROOM_SETTINGS to include shortcutKey for each setting item
- Replace individual keydown event listeners with useShortcuts hook
- Enhance code consistency and maintainability across the application

* refactor: Remove console.log on useDrawingSocket, createSocket

* refactor: Implement zustand selectors across components and hooks

- Replace direct zustand store access with selectors
  • InviteButton
  • NavigationModal
  • RoleModal
  • RoundEndModal
  • QuizStage
  • ToastContainer
  • BrowserNavigationGuard
  • GameHeader
  • GameLayout
- Update hooks to use zustand selectors
  • useCreateRoom
  • useTimer
  • useDrawingState
  • useChatSocket
  • useDrawingSocket
  • useGameSocket
- Improve performance by minimizing unnecessary re-renders
- Enhance code readability and maintainability

* fix: Resolve timer decrement issue in game by updating useTimer hook's dependency array and interval management

* feat: Extract Timer store from gameSocket.store.ts to reduce unnecessary re-renders

- Created a dedicated Timer store using Zustand to manage timer states.
- Implemented actions for updating, decreasing, and resetting timers.
- Ensured that the Timer store operates independently to enhance performance and maintainability.

* feat: Modify Timer logic

- Changed the source of the timer logic from the game socket store to the timer store
- Improved performance by preventing unnecessary re-renders with an independent timer store

* feat: Add win or loss sound

* style: Modify Game Layout min-height to 45rem

* style: Modify volume input from vertical to horizontal

- Change the volume input orientation from vertical to horizontal
- Remove vertical-related styling and logic
- Adjust layout for horizontal display

* feat: Add loading state and message when game start button clicked

* feat: Add roundLoss animation to RoundEndModal

* refactor: Delete SHORTCUT_KEY because it's useless

* refactor: Optimize timer management in useTimer hook

- Simplify dependency array in useEffect to prevent unnecessary re-renders
- Improve timer interval handling to avoid creating/clearing intervals on every render

* fix: Add z-index to GameHeader for proper layering

- Apply z-10 class to GameHeader component to ensure it appears above background elements
- Resolve potential overlap issues with other page elements
  • Loading branch information
rhino-ty authored Dec 4, 2024
1 parent d7fdea8 commit b8b7dd6
Show file tree
Hide file tree
Showing 46 changed files with 604 additions and 407 deletions.
4 changes: 2 additions & 2 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
<meta http-equiv="X-Content-Type-Options" content="nosniff">

<!-- 모바일 관련 설정 -->
<!-- iOS 기기에서 웹 앱을 전체 화면 모드로 실행 가능하게 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- 모바일 기기에서 웹 앱을 전체 화면 모드로 실행 가능하게 -->
<meta name="mobile-web-app-capable" content="yes">
<!-- 상태 바를 반투명한 검은색으로 만들어 웹 앱의 콘텐츠가 상태 바 아래로 확장 가능하게 -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

Expand Down
2 changes: 0 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
},
"dependencies": {
"@lottiefiles/dotlottie-react": "^0.10.1",
"@tanstack/react-query": "^5.59.19",
"@troublepainter/core": "workspace:*",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
Expand All @@ -40,7 +39,6 @@
"@storybook/react": "^8.4.1",
"@storybook/react-vite": "^8.4.1",
"@storybook/test": "^8.4.1",
"@tanstack/react-query-devtools": "^5.59.19",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
Expand Down
19 changes: 2 additions & 17 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastContainer } from '@/components/toast/ToastContainer';

// React Query 클라이언트 인스턴스 생성
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});

interface AppChildrenProps {
children: ReactNode;
}

// 전역 상태 등 추가할 예정
const AppProvider = ({ children }: AppChildrenProps) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

// ErrorBoundary, 모달 등 추가할 예정
const App = ({ children }: AppChildrenProps) => {
return (
<AppProvider>
<>
{children}
<ToastContainer />
</AppProvider>
</>
);
};

Expand Down
6 changes: 3 additions & 3 deletions client/src/api/api.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { checkProduction } from '@/utils/checkProduction';

// 서버 URL
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const PRODUCTION_URL = 'www.troublepainter.site';

// const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';

Expand Down Expand Up @@ -94,8 +95,7 @@ export class ApiError extends Error {
* };
*/
export async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const isProductionHost = window.location.origin.includes(PRODUCTION_URL);
const url = isProductionHost ? `/api${endpoint}` : `${BASE_URL}${endpoint}`;
const url = checkProduction() ? `/api${endpoint}` : `${BASE_URL}${endpoint}`;

const response = await fetch(url, {
...API_CONFIG.OPTIONS,
Expand Down
Binary file modified client/src/assets/lottie/round-loss.lottie
Binary file not shown.
Binary file added client/src/assets/sounds/game-loss.mp3
Binary file not shown.
Binary file added client/src/assets/sounds/game-win.mp3
Binary file not shown.
8 changes: 4 additions & 4 deletions client/src/components/bgm-button/BackgroundMusicButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const BackgroundMusicButton = () => {

return (
<div
className="fixed left-4 top-4 z-30 flex flex-col items-center gap-2 xs:left-8 xs:top-8"
className="fixed left-4 top-4 z-30 flex items-center gap-2 xs:left-8 xs:top-8"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
Expand All @@ -24,14 +24,14 @@ export const BackgroundMusicButton = () => {
)}
aria-label={isMuted ? '배경음악 재생' : '배경음악 음소거'}
>
<img src={soundLogo} className="h-8 w-8 transition-all duration-300" />
<img src={soundLogo} alt="배경음악 토글 버튼" className="h-8 w-8 transition-all duration-300" />
</button>

{/* 볼륨 슬라이더 */}
<div
className={cn(
'flex flex-col items-center rounded-lg bg-chartreuseyellow-500 p-2 transition-all duration-300',
isHovered ? 'translate-y-0 opacity-100' : 'pointer-events-none -translate-y-4 opacity-0',
isHovered ? 'translate-x-0 opacity-100' : 'pointer-events-none -translate-x-4 opacity-0',
)}
>
<input
Expand All @@ -41,7 +41,7 @@ export const BackgroundMusicButton = () => {
step="0.1"
value={volume}
onChange={(e) => adjustVolume(Number(e.target.value))}
className="h-24 w-1 appearance-none rounded-full bg-chartreuseyellow-200 [-webkit-appearance:slider-vertical] [writing-mode:bt-lr]"
className="h-1 w-24 appearance-none rounded-full bg-chartreuseyellow-200"
aria-label="배경음악 볼륨 조절"
/>
</div>
Expand Down
108 changes: 0 additions & 108 deletions client/src/components/chat/Chat.tsx

This file was deleted.

6 changes: 3 additions & 3 deletions client/src/components/chat/ChatBubbleUI.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react';
import { HTMLAttributes, memo } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/utils/cn';

Expand All @@ -22,7 +22,7 @@ export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement>, Variant
nickname?: string;
}

const ChatBubble = ({ className, variant, content, nickname, ...props }: ChatBubbleProps) => {
const ChatBubble = memo(({ className, variant, content, nickname, ...props }: ChatBubbleProps) => {
const isOtherUser = Boolean(nickname);
const ariaLabel = isOtherUser ? `${nickname}님의 메시지: ${content}` : `내 메시지: ${content}`;

Expand All @@ -42,6 +42,6 @@ const ChatBubble = ({ className, variant, content, nickname, ...props }: ChatBub
</p>
</div>
);
};
});

export { ChatBubble, chatBubbleVariants };
16 changes: 16 additions & 0 deletions client/src/components/chat/ChatContatiner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { memo } from 'react';
import { ChatInput } from '@/components/chat/ChatInput';
import { ChatList } from '@/components/chat/ChatList';
import { useChatSocket } from '@/hooks/socket/useChatSocket';

export const ChatContatiner = memo(() => {
// 채팅 소켓 연결 : 최상위 관리
useChatSocket();

return (
<div className="relative flex h-full w-full flex-col">
<ChatList />
<ChatInput />
</div>
);
});
83 changes: 83 additions & 0 deletions client/src/components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { FormEvent, memo, useMemo, useRef, useState } from 'react';
import { PlayerRole, RoomStatus, type ChatResponse } from '@troublepainter/core';
import { Input } from '@/components/ui/Input';
import { chatSocketHandlers } from '@/handlers/socket/chatSocket.handler';
import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler';
import { useShortcuts } from '@/hooks/useShortcuts';
import { useChatSocketStore } from '@/stores/socket/chatSocket.store';
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
import { useSocketStore } from '@/stores/socket/socket.store';

export const ChatInput = memo(() => {
const [inputMessage, setInputMessage] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);

// 개별 Selector
const isConnected = useSocketStore((state) => state.connected.chat);
const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId);
const players = useGameSocketStore((state) => state.players);
const roomStatus = useGameSocketStore((state) => state.room?.status);
const roundAssignedRole = useGameSocketStore((state) => state.roundAssignedRole);
// 챗 액션
const chatActions = useChatSocketStore((state) => state.actions);

const shouldDisableInput = useMemo(() => {
const ispainters = roundAssignedRole !== PlayerRole.GUESSER;
const isDrawing = roomStatus === 'DRAWING' || roomStatus === 'GUESSING';
return ispainters && isDrawing;
}, [roundAssignedRole, roomStatus]);

const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!isConnected || !inputMessage.trim()) return;
void chatSocketHandlers.sendMessage(inputMessage);

const currentPlayer = players?.find((player) => player.playerId === currentPlayerId);
if (!currentPlayer || !currentPlayerId) throw new Error('Current player not found');

const messageData: ChatResponse = {
playerId: currentPlayerId as string,
nickname: currentPlayer.nickname,
message: inputMessage.trim(),
createdAt: new Date().toISOString(),
};
chatActions.addMessage(messageData);

if (roomStatus === RoomStatus.GUESSING) {
void gameSocketHandlers.checkAnswer({ answer: inputMessage });
}

setInputMessage('');
};

useShortcuts([
{
key: 'CHAT',
action: () => {
// 현재 포커스된 요소가 없거나, 포커스된 요소가 body라면 input을 포커싱
const isNoFocusedElement = !document.activeElement || document.activeElement === document.body;

if (isNoFocusedElement) {
inputRef.current?.focus();
} else if (inputMessage.trim() === '') {
inputRef.current?.blur();
}
},
disabled: !inputRef.current, // input ref가 없을 때는 비활성화
},
]);

return (
<form onSubmit={handleSubmit} className="mt-1 w-full">
<Input
ref={inputRef}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="메시지를 입력하세요"
maxLength={100}
disabled={!isConnected || shouldDisableInput}
autoComplete="off"
/>
</form>
);
});
32 changes: 32 additions & 0 deletions client/src/components/chat/ChatList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { memo } from 'react';
import { ChatBubble } from '@/components/chat/ChatBubbleUI';
import { useScrollToBottom } from '@/hooks/useScrollToBottom';
import { useChatSocketStore } from '@/stores/socket/chatSocket.store';
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';

export const ChatList = memo(() => {
const messages = useChatSocketStore((state) => state.messages);
const currentPlayerId = useGameSocketStore((state) => state.currentPlayerId);
const { containerRef } = useScrollToBottom([messages]);

return (
<div ref={containerRef} className="flex h-full flex-col gap-2 overflow-y-auto">
<p className="mb-7 text-center text-xl text-eastbay-50">
여기에다가 답하고
<br /> 채팅할 수 있습니다.
</p>

{messages.map((message) => {
const isOthers = message.playerId !== currentPlayerId;
return (
<ChatBubble
key={`${message.playerId}-${message.createdAt}`}
content={message.message}
nickname={isOthers ? message.nickname : undefined}
variant={isOthers ? 'default' : 'secondary'}
/>
);
})}
</div>
);
});
Loading

0 comments on commit b8b7dd6

Please sign in to comment.