Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .Jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-02-19 - Frontend State Reference Instability
**Learning:** The `useGameSocket` and `useGameState` hooks update the entire `GameState` object reference on every update (e.g., polling or WebSocket message). This causes `Object.values(state.players)` to produce new array and object references every time, defeating default `React.memo` optimizations for child components like `PlayerCard`.
**Action:** When memoizing components derived from `GameState`, always implement a custom comparison function (e.g., `arePropsEqual`) that performs deep comparison on relevant fields instead of relying on reference equality.
19 changes: 2 additions & 17 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 35 additions & 8 deletions frontend/src/components/game/PlayerList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, memo, useCallback } from 'react';
import { Card, Tag, theme, Button, Modal } from 'antd';
import { getRoleNameWithEmoji } from '../../utils/roleUtils';
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
Expand All @@ -10,10 +10,36 @@ interface PlayerCardProps {
player: Player;
isMe: boolean;
canKick: boolean;
onKick: () => void;
onKick: (player: Player) => void;
}

function PlayerCard({ player, isMe, canKick, onKick }: PlayerCardProps) {
function arePlayersEqual(p1: Player, p2: Player) {
return (
p1.id === p2.id &&
p1.nickname === p2.nickname &&
p1.role === p2.role &&
p1.is_alive === p2.is_alive &&
p1.is_admin === p2.is_admin &&
p1.is_spectator === p2.is_spectator &&
p1.is_online === p2.is_online
);
}

function arePlayerCardPropsEqual(prev: PlayerCardProps, next: PlayerCardProps) {
return (
prev.isMe === next.isMe &&
prev.canKick === next.canKick &&
prev.onKick === next.onKick &&
arePlayersEqual(prev.player, next.player)
);
}

const PlayerCard = memo(function PlayerCard({
player,
isMe,
canKick,
onKick,
}: PlayerCardProps) {
const { token } = useToken();
const statusColor = player.is_online ? token.colorSuccess : token.colorError;

Expand Down Expand Up @@ -67,7 +93,7 @@ function PlayerCard({ player, isMe, canKick, onKick }: PlayerCardProps) {
type="text"
danger
icon={<DeleteOutlined style={{ fontSize: 20 }} />}
onClick={onKick}
onClick={() => onKick(player)}
style={{
width: 44,
height: 44,
Expand Down Expand Up @@ -125,7 +151,8 @@ function PlayerCard({ player, isMe, canKick, onKick }: PlayerCardProps) {
</div>
</div>
);
}
},
arePlayerCardPropsEqual);

interface PlayerListProps {
players: Player[];
Expand All @@ -138,9 +165,9 @@ export function PlayerList({ players, myId, onKick }: PlayerListProps) {
const amIAdmin = me?.is_admin ?? false;
const [kickTarget, setKickTarget] = useState<Player | null>(null);

const handleKickClick = (player: Player) => {
const handleKickClick = useCallback((player: Player) => {
setKickTarget(player);
};
}, []);

const confirmKick = () => {
if (kickTarget && onKick) {
Expand All @@ -165,7 +192,7 @@ export function PlayerList({ players, myId, onKick }: PlayerListProps) {
player={player}
isMe={player.id === myId}
canKick={amIAdmin && player.id !== myId && !!onKick}
onKick={() => handleKickClick(player)}
onKick={handleKickClick}
/>
))}
</div>
Expand Down