diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index e6e117b..2b7d4c8 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Routes, Route, useNavigate, useLocation, useParams } from 'react-router-dom' import { Lobby } from '../ui/Lobby' import { GameView, MultiplayerGameView } from '../ui/GameView' @@ -71,10 +71,20 @@ function GameRoute({ const { code: urlCode } = useParams<{ code?: string }>() const navigate = useNavigate() - // `joining` gates rendering while we fire the auto-join request for - // a shared-link arrival. Null = no join in flight. During this window - // the page shows a minimal "Joining…" placeholder. - const [joining, setJoining] = useState(null) + // Synchronous ref guarding against React StrictMode's dev-mode + // double-effect. State updates from the first effect invocation + // don't propagate to the second (both run before React commits), so + // a state guard would be insufficient — both passes would see null, + // both fire room.join, and the second gets ROOM_FULL from the + // server because the first already occupied the second slot. + // + // Refs update synchronously: setting `joinedCodeRef.current` in one + // effect is visible in the StrictMode second invocation immediately. + // + // The "Joining…" placeholder is driven off the absence of mpCreds + // plus the presence of urlCode — no separate `joining` state + // needed. + const joinedCodeRef = useRef(null) const [mpCreds, setMpCreds] = useState(() => { const code = sessionStorage.getItem('room-code') @@ -94,39 +104,31 @@ function GameRoute({ }, [urlCode, mpCreds, navigate]) // Case 4: /game/:code with no matching creds → auto-join the room - // and stash the returned creds. Run once per URL code. - // - // We deliberately INCLUDE `mpCreds`/`joining` in the dep array so the - // effect is always consistent with the exhaustive-deps lint rule, and - // use the top-of-effect guards to prevent re-entry: - // - returns early if URL has no code - // - returns early once mpCreds is populated (we're done) - // - returns early if a join for this exact code is already in flight - // - // After a successful join, `setMpCreds({...})` triggers a re-render, - // the effect re-runs, and the `mpCreds !== null` guard short-circuits - // it cleanly without loops. + // and stash the returned creds. Guarded against StrictMode double- + // invocation by `joinedCodeRef`. useEffect(() => { if (urlCode === undefined) return if (mpCreds !== null) return const normalised = urlCode.toUpperCase() - if (joining === normalised) return - setJoining(normalised) + if (joinedCodeRef.current === normalised) return + joinedCodeRef.current = normalised oneShotRoomRequest('room.join', { code: normalised }) .then(({ code, token, color }) => { sessionStorage.setItem('room-code', code) sessionStorage.setItem('room-token', token) sessionStorage.setItem('player-color', color) setMpCreds({ code, token }) - setJoining(null) }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : 'Could not join room' toast.error(`Join failed: ${msg}`) + // Reset the ref so a retry from the lobby isn't permanently + // blocked by a stale "already attempted this code" flag. + joinedCodeRef.current = null navigate('/', { replace: true }) }) - }, [urlCode, mpCreds, joining, navigate]) + }, [urlCode, mpCreds, navigate]) if (urlCode !== undefined && mpCreds === null) { return (