fix(game): move all hooks above early return to fix React error #310
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m36s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m4s

The connecting overlay used an early return before useState/useSoundStore/
useViewportWidth/useMemo/useEffect were called. On the first render with
seatPlayers=[] those hooks were skipped; on the next render (state arrived)
React tried to call more hooks than before → error #310 crashed all clients.

Fix: hoist all hook declarations above the connecting guard so the count
is always identical regardless of which branch renders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-20 08:35:20 +03:30
parent 3875141f46
commit 856fbab701
+26 -32
View File
@@ -57,50 +57,27 @@ export function GameTable({
const mode = useGameStore((s) => s.mode);
const seatPlayers = useGameStore((s) => s.seatPlayers);
const { t } = useI18n();
// While waiting for the first server state broadcast, show a spinner instead
// of an empty green felt — prevents the "3 colored dots / stuck table" bug.
const connecting = mode === "online" && seatPlayers.length === 0;
if (connecting) {
return (
<main className="persian-pattern relative h-dvh w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className="size-14 border-4 border-gold-400/30 border-t-gold-400 rounded-full"
/>
<p className="gold-text font-bold text-lg">{t("mm.searching")}</p>
</div>
</main>
);
}
// All hooks must be declared unconditionally before any early return.
const [askFf, setAskFf] = useState(false);
const sfx = useSoundStore((s) => s.sfx);
const music = useSoundStore((s) => s.music);
const toggleAll = useSoundStore((s) => s.toggleAll);
const muted = !sfx && !music;
const exit = onExit ?? reset;
const vw = useViewportWidth();
// Pull the played-card pile inward on narrow screens so it clears the side stacks.
const trickScale = vw < 400 ? 0.82 : 1;
// Smaller played cards on phones so the center pile stays clear of the side seats.
const trickCardSize: "sm" | "md" = vw < 480 ? "sm" : "md";
const playHuman = useGameStore((s) => s.playHuman);
const chooseTrump = useGameStore((s) => s.chooseTrump);
const { phase, players, hakem, trump, turn, currentTrick } = game;
const legalMovesList = useMemo(
() => (phase === "playing" && turn === 0 ? legalMoves(game, 0) : []),
[phase, turn, game]
);
const legalIds = new Set(legalMovesList.map((c) => c.id));
// Keyboard shortcuts (desktop): 19 / 0 play the Nth playable card in hand
// order, Space/Enter play the first playable card, M mutes, F forfeits,
// Esc/Q quits. A floating hint lists them.
const playHuman = useGameStore((s) => s.playHuman);
const chooseTrump = useGameStore((s) => s.chooseTrump);
// Derived (non-hook) values — safe to compute after all hooks.
const muted = !sfx && !music;
const exit = onExit ?? reset;
const trickScale = vw < 400 ? 0.82 : 1;
const trickCardSize: "sm" | "md" = vw < 480 ? "sm" : "md";
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
@@ -133,6 +110,23 @@ export function GameTable({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, turn, hakem, game.players, legalMovesList]);
// Connecting overlay: all hooks are already called above, so this early
// return is safe and won't cause a "more/fewer hooks" error on re-render.
if (mode === "online" && seatPlayers.length === 0) {
return (
<main className="persian-pattern relative h-dvh w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: "linear" }}
className="size-14 border-4 border-gold-400/30 border-t-gold-400 rounded-full"
/>
<p className="gold-text font-bold text-lg">{t("mm.searching")}</p>
</div>
</main>
);
}
return (
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
{/* Top HUD */}