From a7ec140cefb69fd47b681c2514ba838594a4674c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 16:26:35 -0600 Subject: [PATCH] fix(chess): use ref-based guard for shared-link auto-join to prevent StrictMode double-join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 18 StrictMode runs effects twice on mount in dev. The previous state-based guard (joining) couldn't prevent the second invocation from firing room.join because both passes see the deferred state as null. The server accepts the first join as black, rejects the second with ROOM_FULL, and the user sees an incorrect error toast. Switching to useRef gives synchronous visibility — the second pass sees joinedCodeRef.current already set and bails. Ref is reset on .catch so a legitimate retry from the lobby isn't permanently blocked. --- packages/chess/src/app/App.tsx | 44 ++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 21 deletions(-) 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 (